Skip to main content

Build a DLR-only webhook consumer

What you’ll build

A receiver that handles delivery receipts only — the message.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 on message.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_message carry the raw provider reason when one was captured.

Subscribe

Register an endpoint with just the four lifecycle events:
Copy the returned 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 every message.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)

Your queue worker resolves each event into a status transition; message.failed branches on data.status:

Python (Flask)

Persist 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 fire message.failed with the matching data.status.
  • WhatsApp — receipt statuses fold the same way, with read receipts additionally firing message.read. A receipt missing for 24 hours ages to undelivered; a late genuine receipt still fires a correcting message.delivered (or message.read) after the terminal event — accept corrections.
  • Emaildelivered means the receiving mail server accepted the message. A bounce, complaint, suppression, or provider-side send failure arrives as message.failed with status: "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.answered is the delivered equivalent, call.failed the terminal non-completion) instead of message events.
The detailed per-channel caveats — windows, corrections, and the Meta DM no-DLR case — are on Wire delivery-report webhooks per channel.

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 via GET /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:
Walk the rest of the trailing digits before you go live — 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.