Webhooks
Dialer Digital exposes public, signed webhook endpoints so SMS providers can push delivery receipts and inbound (mobile-originated) messages into your account:
POST /webhooks/sms/{provider_id}/dlr # delivery receipts
POST /webhooks/sms/{provider_id}/inbound # consumer replies (STOP, HELP, …){provider_id} is the id of a provider you registered at POST /v1/sms/providers. These endpoints take no bearer key — every request is authenticated by a cryptographic signature over the raw body, verified in constant time before the JSON is even parsed. Exact schemas: webhook endpoint reference.
Direction of travel
These are endpoints we host and your SMS provider calls. Outbound webhooks — platform events pushed to your servers — are a separate surface with its own signature scheme: see Outbound webhooks (CRM integration).
Step 0 — register the provider and its secret
When you register (or update) an SMS provider you set its webhook_secret. The field is write-only: responses only ever tell you has_secret: true.
curl -s -X POST $API/v1/sms/providers \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"adapter": "http",
"region": "us",
"name": "my-sms-gateway",
"base_url": "https://gateway.example.com/send",
"webhook_secret": "example-shared-secret-not-real"
}'The response contains the provider id — your webhook URLs are derived from it:
https://api.usa.dialerdigital.com/webhooks/sms/<provider-id>/dlr
https://api.usa.dialerdigital.com/webhooks/sms/<provider-id>/inboundSigning requests — the http adapter contract
For http (and mock) providers, each webhook request must carry one header:
x-dd-signature: <hex of HMAC-SHA256(webhook_secret, raw_body)>Step by step:
- Serialize the JSON payload once and keep the exact bytes — the signature covers the raw body, byte for byte. Re-serializing (key order, whitespace) breaks it.
- Compute
HMAC-SHA256over those bytes with the sharedwebhook_secret. - Hex-encode the digest (lowercase) into
x-dd-signature. POSTwithContent-Type: application/json.
Worked example
Payload:
{"message_id":"prov-000123","status":"delivered"}Shell:
BODY='{"message_id":"prov-000123","status":"delivered"}'
SECRET='example-shared-secret-not-real'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -s "https://api.usa.dialerdigital.com/webhooks/sms/$PROVIDER_ID/dlr" \
-H "Content-Type: application/json" \
-H "x-dd-signature: $SIG" \
--data-raw "$BODY"Node.js:
import { createHmac } from 'node:crypto'
const body = JSON.stringify({ message_id: 'prov-000123', status: 'delivered' })
const signature = createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(body, 'utf8')
.digest('hex')
await fetch(`https://api.usa.dialerdigital.com/webhooks/sms/${providerId}/dlr`, {
method: 'POST',
headers: { 'content-type': 'application/json', 'x-dd-signature': signature },
body
})Python:
import hashlib, hmac, json, os, urllib.request
body = json.dumps({"message_id": "prov-000123", "status": "delivered"}).encode()
sig = hmac.new(os.environ["WEBHOOK_SECRET"].encode(), body, hashlib.sha256).hexdigest()
req = urllib.request.Request(
f"https://api.usa.dialerdigital.com/webhooks/sms/{provider_id}/dlr",
data=body,
headers={"Content-Type": "application/json", "x-dd-signature": sig},
)
urllib.request.urlopen(req)Telnyx providers — Ed25519, zero code on your side
For adapter: "telnyx" you do not compute anything:
- Copy your account's Ed25519 public key from the Telnyx portal (base64).
- Store it as the provider's
webhook_secret(yes, the public key goes in that field for Telnyx rows). - Point Telnyx's webhook URLs at the
/dlrand/inboundendpoints above.
The platform verifies each event exactly as Telnyx specifies: the Ed25519 signature in telnyx-signature-ed25519 is checked over "{telnyx-timestamp}|{raw_body}" against your public key, with a ±5 minute replay window on telnyx-timestamp.
What the endpoint answers
| Response | Meaning |
|---|---|
200 {"status": "ok"} | Recorded. |
200 {"status": "duplicate"} | Replay of an already-recorded event — safely collapsed (idempotent). |
200 {"status": "ignored"} | Signature verified but the event could not be attributed to a message — acknowledged so the provider stops retrying. |
401 unauthorized | Bad or missing signature, or no secret configured. Verification happens before any database access. |
404 not_found | Unknown, inactive or malformed provider id — existence is never leaked. |
413 | Body over 1 MB. |
500 | The ledger write failed — retry; the endpoint is idempotent, so replays are safe. |
Inbound keywords: opt-outs handled for you
Consumer replies to /inbound are processed by the platform itself (English and Spanish, case-insensitive):
| Keyword | Effect |
|---|---|
STOP, ALTO, UNSUBSCRIBE, CANCEL, QUIT, END | The number is placed on your internal do-not-contact list — the same durable store the compliance gates read, so the very next send to it blocks (consent_revoked / dnc_listed). Consent is revoked and a confirmation reply is sent. |
HELP, AYUDA | A canned help reply is sent. |
START | Re-opt-in: SMS consent re-granted, internal listing removed. Regulatory DNC lists are never touched. |
| anything else | Recorded in the SMS ledger and streamed (masked) to the live floor as sms.received. |
You do not have to build opt-out handling to be compliant — replies take effect in the gate store before your code ever sees them.