> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a DLR-only webhook consumer

> A minimal, durable receiver for delivery-status events only — subscribe to the four message lifecycle events with branch-on-status failure handling, verify HMAC, dedupe on the event id, map per-channel payload shapes, and rehearse failure codes against the sandbox magic numbers.

# 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](/guides/webhook-consumer). The per-channel vocabulary this page wires into a receiver is documented on [Wire delivery-report webhooks per channel](/guides/wire-dlr-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:

| Event               | Meaning                                                                           | `data.status` values to expect                                         |
| ------------------- | --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `message.sent`      | Orbit handed the message to the channel — send-side ack, not a delivery proof.    | `sent`                                                                 |
| `message.delivered` | The channel confirmed receipt (for email, the receiving mail server accepted it). | `delivered`                                                            |
| `message.read`      | The recipient opened it (WhatsApp, RCS, Viber only).                              | `read`                                                                 |
| `message.failed`    | Terminal non-delivery.                                                            | `failed`, `undelivered`, `rejected`, `expired`, `submitted_no_receipt` |

The event-name-to-status mapping is the [message status transition rules](/concepts/message-status-dag) DAG applied at the webhook layer — terminal statuses with no dedicated event literal fold onto `message.failed` per the [single message-status map](/concepts/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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks/endpoints \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/orbit-dlr",
    "events": ["message.sent", "message.delivered", "message.read", "message.failed"]
  }'
```

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](/guides/wire-dlr-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)

```javascript theme={null}
import express from "express";
import crypto from "node:crypto";

const DLR_EVENTS = new Set([
  "message.sent",
  "message.delivered",
  "message.read",
  "message.failed",
]);

const app = express();

app.post(
  "/webhooks/orbit-dlr",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header = req.headers["x-orbit-signature"] ?? req.headers["x-devotel-signature"];
    const raw = req.body; // Buffer of the raw payload

    if (!header || !verifySignature(raw, String(header), process.env.ORBIT_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    let event;
    try {
      event = JSON.parse(raw.toString("utf8"));
    } catch {
      return res.status(400).json({ error: "Missing event id" });
    }
    if (!event?.id) return res.status(400).json({ error: "Missing event id" });

    // Dedupe before any work — retries re-deliver the same id.
    if (!seenEvents.remember(event.id)) {
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Non-DLR events on a shared endpoint are acked and dropped here.
    if (!DLR_EVENTS.has(event.type)) {
      return res.status(200).json({ received: true, skipped: true });
    }

    queue.enqueue(event);
    return res.status(200).json({ received: true });
  }
);

function verifySignature(rawBody, header, secret) {
  let t = null;
  const v1s = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") t = value;
    else if (key === "v1") v1s.push(value);
  }
  if (!t || v1s.length === 0) return false;

  const age = Math.floor(Date.now() / 1000) - Number(t);
  if (!Number.isFinite(age) || age < 0 || age > 5 * 60) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody.toString("utf8")}`, "utf8")
    .digest("hex");

  return v1s.some((candidate) => {
    const a = Buffer.from(candidate, "hex");
    const b = Buffer.from(expected, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}
```

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

```javascript theme={null}
switch (event.type) {
  case "message.sent":
    return markSent(event.data.message_id);
  case "message.delivered":
    return markDelivered(event.data.message_id);
  case "message.read":
    return markRead(event.data.message_id);
  case "message.failed":
    return resolveFailure(event.data.message_id, event.data.status, event.data.error_code);
}
```

### Python (Flask)

```python theme={null}
import os
import hmac
import hashlib
import time
import json

from flask import Flask, request, jsonify

DLR_EVENTS = {
    "message.sent",
    "message.delivered",
    "message.read",
    "message.failed",
}

app = Flask(__name__)
SECRET = os.environ["ORBIT_WEBHOOK_SECRET"]


@app.route("/webhooks/orbit-dlr", methods=["POST"])
def handle():
    header = request.headers.get("X-Orbit-Signature") or request.headers.get(
        "X-Devotel-Signature", ""
    )
    raw = request.get_data()  # raw bytes, untouched by any parser
    if not verify_signature(raw, header, SECRET):
        return jsonify({"error": "Invalid signature"}), 401

    try:
        event = json.loads(raw)
    except ValueError:
        return jsonify({"error": "Missing event id"}), 400

    event_id = event.get("id")
    if not event_id:
        return jsonify({"error": "Missing event id"}), 400

    if not seen_events.remember(event_id):
        return jsonify({"received": True, "duplicate": True}), 200

    if event.get("type") not in DLR_EVENTS:
        return jsonify({"received": True, "skipped": True}), 200

    queue.enqueue(event_id=event_id, body=event)
    return jsonify({"received": True}), 200


def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    t = None
    v1s = []
    for part in header.split(","):
        part = part.strip()
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            v1s.append(value)
    if not t or not v1s:
        return False

    age = int(time.time()) - int(t)
    if age < 0 or age > 5 * 60:
        return False

    signed = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(candidate, expected) for candidate in v1s)
```

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](/concepts/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.
* **Email** — `delivered` 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](/concepts/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](/guides/wire-dlr-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](/concepts/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](/troubleshooting/submitted-no-receipt).

## 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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: dv_test_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "to": "+15005550003", "from": "+15550100", "body": "DLR consumer rehearsal" }'
```

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](/guides/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](/concepts/dlr-and-mo-pipeline) — subscribing to DLR events sends no traffic anywhere new.

## Related

* [Build a durable webhook consumer](/guides/webhook-consumer) — the full endpoint hardening guide this page trims
* [Wire delivery-report webhooks per channel](/guides/wire-dlr-webhooks-per-channel) — per-channel vocabulary and caveats
* [Webhook delivery semantics](/concepts/webhook-delivery-semantics) — retries, proven-dead responses, the dead-letter queue
* [Message status transition rules](/concepts/message-status-dag) — the merge contract behind `data.status`
* [The single message-status map](/concepts/message-status-map) — resolve any status to its owning page
* [Sandbox magic numbers playbook](/guides/sandbox-magic-numbers-playbook) — the full trailing-digit table
