Build a DLR-only webhook consumer
What you’ll build
A receiver that handles delivery receipts only — themessage.sent, message.delivered, message.read, and message.failed events — with the durability mechanics trimmed to exactly what a status transition needs. Use it when you want “tell me what happened to each message” without building the general-purpose endpoint from Build a durable webhook consumer. The per-channel vocabulary this page wires into a receiver is documented on Wire delivery-report webhooks per channel.
The event set
Four events carry the outbound message lifecycle; every non-success terminal status rides onmessage.failed, so your failure branch keys off the payload’s data.status, not the event name:
The event-name-to-status mapping is the message status transition rules DAG applied at the webhook layer — terminal statuses with no dedicated event literal fold onto
message.failed per the single message-status map. Your failure branch should therefore switch on data.status:
undelivered— the carrier tried; the handset is unreachable. Stop retrying and fix the list.rejected— a policy or spam-filter block. Back off; don’t retry immediately.expired— a delivery receipt arrived past the late-arrival window and the row closed as carrier-stale.submitted_no_receipt— the no-receipt safety net aged the row out (30 minutes on SMPP-backed SMS, 5 minutes on Meta DM, 24 hours on Viber/WhatsApp, 60 minutes on email).failed— a dispatch-time or classified carrier failure;data.error_code/data.error_messagecarry the raw provider reason when one was captured.
Subscribe
Register an endpoint with just the four lifecycle events:whsec_... secret now — it is shown exactly once. One endpoint covers every messaging channel; branch on data.channel inside your handler. Voice delivery outcomes use the call.* family instead — see Wire delivery-report webhooks per channel.
The minimal consumer
Receive, verify, dedupe, ack, and hand the status transition to your own queue. Both receivers below resolve everymessage.failed event to the terminal status and log the failure branch; the Node example also passes the payload to your downstream handler.
Node.js (Express)
message.failed branches on data.status:
Python (Flask)
seen_events in your database with a 7-day minimum retention — the dedupe window must span the retry schedule plus dead-letter replay, per Webhook delivery semantics.
Channel payload mapping
The envelope is identical on every channel:id, type, created_at, and a data object with message_id, status, channel, timestamp, and optional error_code / error_message. What differs is what the status vocabulary means per channel:
- SMS (SMPP-backed carriers) — receipts fold into the canonical statuses: a delivered receipt fires
message.delivered;undelivered,rejected,expired, and the 30-minute no-receipt aging firemessage.failedwith the matchingdata.status. - WhatsApp — receipt statuses fold the same way, with
readreceipts additionally firingmessage.read. A receipt missing for 24 hours ages toundelivered; a late genuine receipt still fires a correctingmessage.delivered(ormessage.read) after the terminal event — accept corrections. - Email —
deliveredmeans the receiving mail server accepted the message. A bounce, complaint, suppression, or provider-side send failure arrives asmessage.failedwithstatus: "failed"; complaints additionally suppress the address. The provider-event-to-status mapping is on Email delivery lifecycle. - Voice — has no delivery concept. Subscribe to the
call.*lifecycle (call.answeredis the delivered equivalent,call.failedthe terminal non-completion) instead of message events.
Retries and the dead-letter queue
Delivery is at-least-once: one initial attempt plus nine retries on a 30 s → 60 s → … doubling backoff (up to 20% jitter), then the event moves to the dead-letter queue roughly 4.3 hours after the first attempt. Dead-lettered events stay replayable for 7 days from Developer → Webhooks → Dead-letter queue or viaGET /api/v1/webhooks/dlq and POST /api/v1/webhooks/dlq/<delivery_id>/requeue. Keep your dedupe ids for the full window so a replay does not double-apply a transition. The full model — including the proven-dead responses (401, 403, 404, 410) that skip retries — is Webhook delivery semantics.
When a submitted_no_receipt failure event lands, do not treat it as proof of non-delivery: it means the channel accepted the message but no receipt came back before the channel’s window. A late receipt corrects the row. If these events accumulate, the provider is not returning receipts — work the submitted, no receipt troubleshooting flow.
Rehearse with the sandbox
Mint a sandbox key (dv_test_sk_), register your endpoint with it, and drive a failure trigger — trailing digit 3 produces sent → undelivered about a second apart, exercising both the intermediate and the failure branch:
2 delivered, 4 pre-submit failure with no sent ack, 7 rejected, 8 blocked, 9 delayed delivery — from the sandbox magic numbers playbook. Assert against your own status mirror: the sent event must not mark the row terminal, and the message.failed branch must record undelivered, not a generic failure.
Compliance
Everything on this page is a tenant-owned control: which events you subscribe to, which URL receives them, and how your consumer reacts to each status. Outbound delivery stays within the Devotel softswitch contract described in DLR and MO gateway pipeline — subscribing to DLR events sends no traffic anywhere new.Related
- Build a durable webhook consumer — the full endpoint hardening guide this page trims
- Wire delivery-report webhooks per channel — per-channel vocabulary and caveats
- Webhook delivery semantics — retries, proven-dead responses, the dead-letter queue
- Message status transition rules — the merge contract behind
data.status - The single message-status map — resolve any status to its owning page
- Sandbox magic numbers playbook — the full trailing-digit table