Webhook endpoints (provider-facing)
Generated from
DialerDigital/corecommit6e9f026f0a72— specopenapi/dialer-v1.yamlv0.36.0. Do not edit by hand; see How this reference is built. Download the OpenAPI spec.
These are the public, signature-authenticated endpoints the platform exposes to SMS providers (delivery receipts and mobile-originated messages). They do not take a bearer key — every request is verified against the per-provider signature before anything else happens. For the verification walkthrough, see Webhooks.
Delivery receipt — POST /webhooks/sms/{provider_id}/dlr
POST /webhooks/sms/{provider_id}/dlrServed at POST /webhooks/sms/{provider_id}/dlr where {provider_id} is a row of /v1/sms/providers. NO bearer auth — per-provider signature over the RAW request body, verified in constant time BEFORE any JSON decode:
- Internal contract (
http/mockadapters): headerx-dd-signature= HMAC-SHA256-hex(webhook_secret, raw_body). telnyxadapter: Ed25519 over"{telnyx-timestamp}|{raw_body}"against the row's public key, headerstelnyx-signature-ed25519+telnyx-timestamp, ±5 min replay window; payloads are the Telnyx v2 envelope normalized by the adapter.
Unknown/inactive/non-uuid provider id → 404 (no existence leak); bad/missing signature or no configured secret → 401 with ZERO table touches. Idempotent: DLR replays collapse (200 {"status": "duplicate"}); events that verify but cannot be attributed answer 200 {"status": "ignored"}. A ledger write failure answers 500 so the provider retries. Bodies above 1 MB → 413.
Auth: none — authenticated by request signature (see below).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
x-dd-signature | header | string | no | Internal contract — HMAC-SHA256 hex over the raw body. |
telnyx-signature-ed25519 | header | string | no | Telnyx — Ed25519 signature (base64). |
telnyx-timestamp | header | string | no | Telnyx — unix timestamp signed with the body (±5 min window). |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
message_id | string | yes | Provider message id from the send ACCEPT. |
status | sent · delivered · undelivered · failed | yes |
{
"message_id": "mock-0001",
"status": "delivered"
}Responses
200
Recorded as a compensating status row (or collapsed replay / unattributable event).
| Field | Type | Required | Description |
|---|---|---|---|
status | ok · duplicate · ignored | yes | duplicate = replay collapsed on the ledger's unique indexes; ignored = authenticated but unattributable (logged + counted, not retryable). |
ok
{
"status": "ok"
}duplicate
{
"status": "duplicate"
}ignored
{
"status": "ignored"
}400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Mobile-originated message — POST /webhooks/sms/{provider_id}/inbound
POST /webhooks/sms/{provider_id}/inboundServed at POST /webhooks/sms/{provider_id}/inbound. Same signature model as the DLR webhook (see smsDeliveryReceipt). Keyword handling (normalized, EN+ES): STOP/ALTO/UNSUBSCRIBE/CANCEL/QUIT/END → internal-DNC listing (the same durable store the gates read — the next send blocks) + consent revocation + canned confirmation reply; HELP/AYUDA → canned help reply; START → re-opt-in (consent re-granted for SMS, internal-DNC listing removed — regulatory lists are never touched); anything else → ledger row + masked sms.received live-floor frame. All idempotent; unattributable events answer 200 {"status": "ignored"}.
Auth: none — authenticated by request signature (see below).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
x-dd-signature | header | string | no | Internal contract — HMAC-SHA256 hex over the raw body. |
telnyx-signature-ed25519 | header | string | no | Telnyx — Ed25519 signature (base64). |
telnyx-timestamp | header | string | no | Telnyx — unix timestamp signed with the body (±5 min window). |
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
from | string | yes | Consumer's number, E.164. |
to | string | yes | The tenant DID that received the message. |
body | string | yes | |
message_id | string | yes |
{
"from": "+19995550123",
"to": "+13125550199",
"body": "STOP",
"message_id": "mock-mo-0001"
}Responses
200
Handled (keyword action, ledger row, replay collapse or ignore).
| Field | Type | Required | Description |
|---|---|---|---|
status | ok · duplicate · ignored | yes | duplicate = replay collapsed on the ledger's unique indexes; ignored = authenticated but unattributable (logged + counted, not retryable). |
400 — BadRequest
Malformed request (shape/type errors, invalid filters, bad timestamps).
{
"error": {
"code": "bad_request",
"message": "from must be RFC-3339"
}
}401 — Unauthorized
Missing, unknown or revoked API key (or, on /v1/admin/*, a bad/unset admin token; on webhooks, a bad/missing signature).
{
"error": {
"code": "unauthorized",
"message": "invalid or revoked API key"
}
}404 — NotFound
Unknown id, malformed (non-UUID) id OR another tenant's id — RLS returns zero rows, so all three are indistinguishable by design (no existence leak, never a 403 for foreign ids).
{
"error": {
"code": "not_found",
"message": "resource not found for this tenant"
}
}Outbound — one v1 event POSTed to a registered webhook endpoint (MT-CTI-04)
POST (webhook: crmEvent)The platform is the CALLER here: the body is what a /v1/webhooks endpoint subscribed to the event receives. Phase 2 (core#960, ADR #122) defines the envelope and records one row per (endpoint, event) in the outbox webhook_deliveries, written in the SAME transaction as the domain fact; NOTHING is sent yet — the dispatcher (phase 3, core#961) drains the outbox, signs the body with the endpoint's secret (X-DD-Signature, plus X-DD-Signature-Previous during a rotation window) and retries with a lease.
data is the public projection of the resource, byte-for-byte the shape its GET answers, serialized at the moment of the fact and never rebuilt: call.ended / call.answered → CallAttempt (the finalize correction row for call.ended; the original row with answered_at for call.answered), promise.recorded → Promise, sms.received / sms.optout → SmsMessage (list projection, 40-char body_preview), recording.available → Recording. event_id is the receiver's idempotency key: the same fact is never announced twice to the same endpoint.
Request body (JSON, required)
| Field | Type | Required | Description |
|---|---|---|---|
event | call.ended · call.answered · promise.recorded · sms.received · sms.optout · recording.available | yes | The closed v1 catalog of subscribable events (ADR |
event_id | string | yes | Idempotency key, unique per endpoint: dedupe on it. The id of the row the event announces (call.ended: the finalize correction; promise.recorded: the promise; sms.*: the ledger row; recording.available: the recording); call.answered has no row of its own and uses <original attempt id>:answered. |
occurred_at | string (date-time) | yes | When the fact happened (the call's ended_at/answered_at, the message's occurred_at, ...), not when it was sent. |
tenant_id | string (uuid) | yes | |
data | CallAttempt | Promise | SmsMessage | Recording | yes | The public projection of the resource — the same shape its GET answers. |
{
"event": "promise.recorded",
"event_id": "019968d2-6b1e-7c4a-9b0e-3f2a1c5d7e90",
"occurred_at": "2026-09-17T20:15:30.123456Z",
"tenant_id": "5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60",
"data": {
"id": "019968d2-6b1e-7c4a-9b0e-3f2a1c5d7e90",
"tenant_id": "5a1c3e7d-2f4b-4c8e-9d1a-0b2c3d4e5f60",
"debt_id": "019968d1-0000-7000-8000-000000000001",
"call_attempt_id": null,
"agent_id": null,
"amount_cents": 12500,
"currency": "USD",
"promised_date": "2026-09-27",
"status": "pending",
"note": null,
"created_at": "2026-09-17T20:15:30.123456Z",
"updated_at": "2026-09-17T20:15:30.123456Z"
}
}Responses
2XX
Acknowledged. Any other status (or a timeout) is retried by the dispatcher (phase 3).