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

# Wire delivery-report webhooks per channel, end to end

> One loop that takes a sender from 'which events do I subscribe to per channel' to a verified, debugged subscription: the per-channel DLR vocabulary with its changed semantics, dashboard and API subscription, HMAC verification, the sandbox tester-first walkthrough, and request-id correlation.

# Wire delivery-report webhooks per channel, end to end

You send on SMS, WhatsApp, RCS, email, or voice and you want delivery
outcomes pushed to your server instead of polling the Delivery Log. This
tutorial closes the loop in one pass: pick the right events per channel,
subscribe, verify signatures, rehearse in the sandbox before registering,
and debug a misbehaving delivery by request id.

Pages that overlap this topic each answer a narrower question — the
[event catalog](/guides/webhook-event-catalog) is for browsing payload
schemas and pinning them in CI, the [durable consumer](/guides/webhook-consumer)
guide is endpoint hardening generally, and [DLR outcomes
monitoring](/guides/dlr-outcomes-monitoring) is the WARN-log classification
readout. This page is the wiring tutorial that connects them.

## 1. Event vocabulary per channel

Four message events carry the outbound lifecycle on every messaging
channel, plus the call family for voice:

| Event                                                                 | What it means                                                                                           | Which channels                             |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------ |
| `message.sent`                                                        | Orbit accepted the send and handed it to the channel — the send-side ack. Not a delivery confirmation.  | All messaging channels                     |
| `message.delivered`                                                   | The channel confirmed the message reached the recipient (for email, the receiving mailbox accepted it). | SMS, WhatsApp, RCS, email, Viber, Telegram |
| `message.read`                                                        | The recipient opened the message — a stronger signal than delivered, on channels that report reads.     | WhatsApp, RCS, Viber (never SMS or email)  |
| `message.failed`                                                      | Terminal non-delivery: carrier reject, policy block, no-receipt aging, or a pre-submit failure.         | All messaging channels                     |
| `call.initiated` / `call.answered` / `call.completed` / `call.failed` | The voice lifecycle equivalents — `call.failed` is the non-completion terminal.                         | Voice                                      |

Subscribe to all four message events even if you only care about
failures: `message.sent` is how you distinguish "Orbit never accepted the
send" from "the channel lost it downstream," and `message.delivered`
lets you reconcile that `message.failed` genuinely means terminal.

### The changed semantics you must code against

The event names are stable; what each **means per channel** is not.
These are the rows that change how you wire:

* **Meta DM (Messenger, Instagram): no DLR exists.** Meta's Send API
  never emits delivery receipts, so a DM send can only produce
  `message.sent` followed by either nothing or a safety-net
  `message.failed` reading `submitted_no_receipt` after a 5-minute
  window. Treat `message.sent` as fully accepted on these channels and
  do not alert on the missing `message.delivered` — it will never come.
  See [DLR outcomes monitoring](/guides/dlr-outcomes-monitoring) for the
  designed-benign classification.
* **Viber and WhatsApp: a 24-hour DLR window.** A send that receives no
  receipt inside 24 hours ages to `undelivered` and fires
  `message.failed` — a late genuine receipt still corrects the row and
  re-fires `message.delivered` (or `message.read`). Your consumer must
  accept a correcting event after a terminal one.
* **SMS on SMPP-backed routes: a 30-minute window.** A healthy carrier
  returns receipts well inside 30 minutes. A send with no receipt inside
  that window ages to `undelivered` and fires `message.failed`; a late
  receipt corrects it. A sustained no-receipt rate on this lane is a
  carrier problem, not a platform problem.
* **Email semantics are mailbox-acceptance.** `message.delivered` means
  the receiving server accepted the message — it is not an open or a
  click. Pair it with the engagement events (`email.opened`,
  `email.clicked`) if you need recipient interaction.
* **Voice has no delivery concept.** `call.answered` is the voice
  equivalent of delivered; `call.failed` covers busy, no-answer, and
  carrier failure. Subscribe to the full call lifecycle instead of a
  single event.

Events are at-least-once and can arrive out of order across messages.
Dedupe on the envelope `id`, and key each event to the message by
`data.message_id` — never by arrival order.

## 2. Subscribe

### In the dashboard

Open **Developer → Webhooks**, click **Add endpoint**, paste your HTTPS
URL, and tick the events from the table above. The dashboard shows the
signing secret once — copy it now; it is the value `whsec_...` your
verifier needs in Step 3.

### Over the API

`POST /api/v1/webhooks/endpoints` with the event list per channel you
just mapped:

```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",
    "events": [
      "message.sent",
      "message.delivered",
      "message.read",
      "message.failed",
      "call.initiated",
      "call.answered",
      "call.completed",
      "call.failed"
    ]
  }'
```

The response carries the endpoint id and the signing secret (shown only
here). Subscribing to a name outside the catalog returns `422
VALIDATION_ERROR` — resolve event names against the [events
catalog](/webhooks/events) first. One endpoint can serve several
channels; the `data.channel` field on every message event tells you
which channel the receipt came from, so a single subscription covers
SMS, WhatsApp, RCS, email, and voice if your handler branches on
`data.channel`.

## 3. Verify the signature

Every registered delivery carries `X-Orbit-Signature: t=<unix_ts>,v1=<hex>`
— an HMAC-SHA256 of `<t>.<raw_body>` under your endpoint secret. Verify
before parsing, answer `401` on failure, and never ack an unverified
payload with `200`. The full protocol lives at [Webhook
security](/webhooks/security).

### Node.js (standard library)

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

export function verifyOrbitSignature(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    (header ?? "").split(",").map((kv) => kv.split("=")),
  );
  const timestamp = Number(parts.t);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");
  const received = Buffer.from(parts.v1 ?? "", "hex");
  const expectedBuf = Buffer.from(expected, "hex");
  if (received.length !== expectedBuf.length) return false;
  const fresh = Math.abs(Date.now() / 1000 - timestamp) <= toleranceSec;
  return fresh && crypto.timingSafeEqual(received, expectedBuf);
}
```

### Python (standard library)

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

def verify_orbit_signature(raw_body: bytes, header: str, secret: str,
                           tolerance_sec: int = 300) -> bool:
    """Verify an Orbit webhook signature header.

    raw_body: exact bytes received — verify before any JSON parser runs.
    header: value of X-Orbit-Signature (fall back to X-Devotel-Signature).
    """
    parts = dict(item.split("=", 1) for item in header.split(","))
    timestamp = int(parts.get("t", "0"))
    if abs(time.time() - timestamp) > tolerance_sec:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

Prefer the SDK one-call verifier per language — complete framework
handlers for Node, Python, Go, PHP, Ruby, Java, and C# are on [Verify
webhook signatures](/guides/verify-webhook-signatures), and the standard-
library equivalents are on [Verify without the
SDK](/guides/webhook-signature-verify-polyglot). During a secret rotation
both `X-Orbit-Signature` (current secret) and `X-Orbit-Signature-Next`
(previous secret) arrive until the 7-day grace window ends.

## 4. Sandbox rehearsal — tester first, then a real subscription

Do not register a production endpoint against an untested receiver. Two
rehearsal steps, both without a carrier:

**Step A — fire the Webhook Tester.** Open **Developer → Webhook
Tester**, paste your URL, pick `message.delivered`, and send. The
tester delivers through the real dispatcher but **unsigned** — a
receiver that verifies correctly answers `401/403` to it. Send tester
events to confirm your route exists, your TLS is valid, and your
handler parses the envelope; expect signature verification to reject
them, per the [Webhook Tester](/guides/webhook-tester) checklist.

**Step B — register with a test key and replay a delivery.** Mint a
sandbox key (`dv_test_sk_`), register the endpoint over the API with
that key, and drive a magic-number send. The trailing digit picks the
outcome; trailing digit `3` returns `message.failed` (the stored status
is `undelivered`, a handset reject):

```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 rehearsal" }'
```

Your endpoint receives `message.sent` immediately, then `message.failed`
about a second later — the same sequence a carrier produces on a
handset reject. Verify the signature against the test-endpoint secret,
confirm your failure branch ran, then walk the other digits (`2`
delivered, `4` pre-submit failure with no `sent` ack, `9` delayed
delivery) before you swap in a live key. The full digit table is the
[sandbox magic numbers playbook](/guides/sandbox-magic-numbers-playbook).

<Tip>
  Sandbox receipts exist only on the webhook stream — the stored message
  keeps its test status. If your consumer reads the Delivery Log instead
  of trusting the stream, rehearsal and production behave differently.
</Tip>

## 5. Debug a delivery

When an expected event never lands, or lands and your handler rejects
it, correlate on two handles:

1. **`meta.request_id` on the API response.** Every send response
   carries it. Open **Developer → Request Logs**, find the request by
   id, and confirm Orbit accepted the send — a `422` or `502` at the
   request layer means no DLR events will ever fire.
2. **`data.message_id` (and `data.to`, the recipient id) on the event
   envelope.** Open the Delivery Log filtered by the message id. The
   row's terminal status is the ground truth; the webhook event you
   expected is downstream of the same transition the row shows. Check
   **Developer → Webhooks → your endpoint → Deliveries** for the
   dispatch record — a failing endpoint shows its failed attempts and
   the retry timeline there.

The split is quick: Request Logs answer "did the send get in," the
Delivery Log answers "what outcome did the channel report," and the
endpoint Deliveries panel answers "did the webhook leave and what did
your server return." Work them in that order and every missing event
resolves to one of the three.

## 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 outcome. The channel windows (Meta DM no-DLR, the Viber/WhatsApp
24-hour window, the SMPP 30-minute window) are platform safety nets you
read, not timers you set. 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

* [Webhook events catalog](/webhooks/events) — the canonical list of every event you can subscribe to
* [Build a durable webhook consumer](/guides/webhook-consumer) — queue, dedupe, and ack-fast hardening for the receiver this page wires
* [Verify webhook signatures](/guides/verify-webhook-signatures) — one-call SDK verifiers per language
* [Webhook Tester](/guides/webhook-tester) — the unsigned ad-hoc sender used in Step A
* [DLR outcomes monitoring](/guides/dlr-outcomes-monitoring) — the WARN classification behind the no-receipt terminal events
* [Sandbox magic numbers playbook](/guides/sandbox-magic-numbers-playbook) — the full trailing-digit scenario table
* [Delivery lifecycle](/concepts/delivery-lifecycle) — what each of the six canonical states means
