Skip to main content

Debug inbound carrier webhooks

When a carrier POSTs into Orbit — a Telnyx delivery receipt, an SMPP status code from the Devotel softswitch, a DIDWW callback, a Meta (WhatsApp) sent/delivered/read receipt — Orbit keeps a per-request debug record of what arrived, how the signature checked out, and how the body parsed. This guide shows that record, the Inbound Event Catalog that maps each carrier’s status vocabulary onto a canonical event, and how to validate your receiver against the same mapping with the SDK.

Carrier webhook versus canonical outbound event

Orbit fans every carrier callback out to your webhook endpoint as one signed envelope: { id, type, created_at, data }, where type is a canonical event such as message.delivered or message.failed and data.status is a closed vocabulary (sent, delivered, failed, read, opted_out). The original carrier status string is preserved on data.provider_status, so nothing carriers-specific is lost. Your receiver branches on the canonical type and status — not on Telnyx event_type values, SMPP receipt codes, or Meta statuses[] entries. The Inbound Event Catalog enumerates those subsets so you subscribe to each canonical event exactly once. What the catalog does not do: map inbound usage patterns generically — it covers delivery, failure, opt-out, and received (MO) vocabulary only.

Inspect an inbound webhook record

Open Developer → Webhooks → Inbound in the dashboard. Every callback hitting /api/v1/webhooks/* (plus a few sister surfaces such as Stripe billing) is recorded from the moment it arrives — before signature verification — so even rejected requests leave a row you can inspect. The list shows one row per inbound request:
  • Signature badge — valid, invalid, or missing, against the provider’s signing scheme. A 401 on a forged POST still leaves an invalid row for forensics.
  • Raw body — the request bytes, truncated to 256 KiB per row, with phone numbers and emails masked at insert time. ?reveal=true is never possible: the PII is reduced before the record is written.
  • Parsed event drawer — the decoded JSON the handler parsed, likewise PII-masked. Header values are filtered to an allowlist (content type, request id, and the provider’s signature headers such as telnyx-signature-ed25519, x-meta-signature, x-hub-signature-256).
  • API backing — GET /api/v1/developer/inbound-webhooks for the list, GET /api/v1/developer/inbound-webhooks/:id for the full raw body (raw_body_b64), headers, and parsed event. Filters: channel, provider, signature_status, from, to. Rows live 30 days, then are removed.
Only your tenant’s records are visible; records that could not be attributed (a forged event, a signature failure before routing) never cross tenants on this page. Use the parsed event drawer to see which raw carrier status produced a canonical event: the drawer shows the body the handler decoded, and you compare it against the canonical event you actually received.

Worked example — the list call

Newest rows come first; next_cursor is null on the last page.

Worked example — the detail call

Decode raw_body_b64 with base64 -d to recover the exact bytes the carrier sent (256 KiB ceiling per row; PII was already masked before persistence). headers carries only the allowlisted entries — content type, request id, and the provider’s own signature header — so an invalid badge like the one above tells you the forged POST came in with a bad telnyx-signature-ed25519.

Map a raw provider status with the Inbound Event Catalog

Open Developer → Webhooks → Inbound event catalog. It lists every canonical event a receiver can observe and, for each, the full per-provider status vocabulary that folds into it — so you answer “which canonical event do I subscribe to, and which raw provider statuses will arrive as it?” before writing a receiver branch. The API backing is GET /api/v1/webhooks/inbound-events:
The response is the versioned catalog, version: "v1" plus an events[] array, one entry per canonical event a receiver can observe. One entry, showing the Telnyx UNDLV fold:
Each event entry carries canonical_event, canonical_status, an event kind (delivery, failure, opt_out, or received), and equivalent_upstream — every (provider, raw_status) pair that resolves to it. A Telnyx DLR arriving with message.sending_failed and an SMPP receipt arriving with UNDLV both deliver to your endpoint as the same signed envelope with type: "message.failed" and data.status: "failed". Examples of the fold:
  • Telnyx UNDLV / SMPP DELIVRD family — message.sending_failed, sending_failed, delivery_failed, rejected, undelivered fold onto message.failed (a failure kind, failed status). Telnyx message.sent, queued, sending fold onto message.sent.
  • SMPP codes through the Devotel softswitch — DELIVRD fold onto message.delivered; UNDLV, REJECTD, EXPIRED, and submitted_no_receipt fold onto message.failed.
  • Meta (WhatsApp / Messenger / Instagram) read receipt — the read status folds onto message.read (a delivery kind). A delivered from the same channel folds onto message.delivered.
  • Meta opt-outs — user_opt_out, marketing_opt_out, a STOP keyword, or opt_out fold onto contact.opted_out (a canonical opt-out event, opted_out status).
  • Generic inbound (MO) — mo, inbound, received, message fold onto message.received (a received kind). The “a message arrived” branch is carrier-agnostic by construction.
Search on the catalog page is provider + status + event aware — type undlv and the failure event surfaces immediately, or type telnyx and only that provider’s fold stays in view.

Validate your receiver with the SDK

The one normalizer the platform uses, normalizeInboundEvent, ships in the @devotel/shared/webhooks/inbound-normalization module and pairs with the catalog to let your receiver check itself on the same mapping the platform produced the event with:
Pass the upstream provider when you know it (telnyx, devotel_softswitch, didww, meta, or omit for a cross-provider lookup); match is case-insensitive and the first-hit-wins catalog ordering is part of the product contract. When no mapping matches, normalizeInboundEvent returns undefined and the receiver should fall back to the provider-specific term — the fan-out stays lossy-safe because the raw term is always preserved on data.provider_status while data.status remains the closed canonical set.

Inspect a record from code with the SDK escape hatch

The inbound-webhooks debug surface reads as plain JSON, so the SDK’s low-level request() helper reaches it without a dedicated resource method:
Use it in a script that re-reads a specific id you pulled from the dashboard, or in a cron that pages the endpoint and hunts for a pattern the catalog search can’t express.

Triage a signature-failure spike

The most common sweep: fold every invalid-signature POST you received from Telnyx into a single list call, then pull each row’s detail to compare the telnyx-signature-ed25519 header against your key rotation history:
Replace signature_status=invalid with missing to catch forge attempts with no signature header at all. Every row the filter returns is one POST that got a 401 — the spike is visible even when the ingestion pipeline itself was healthy.

Troubleshoot carrier-specific POSTs

Round the most common failure shapes off the per-record detail page:
  • Signature badge is invalid or missing — the provider signature header is either absent or rejected. The allowlist keeps the provider’s own signature header (for example telnyx-signature-ed25519, x-meta-signature) on the record, so you can compare the exact header and timestamp the carrier sent. A 401 was already returned for it, but the record still exists so the forgery trail stays auditable.
  • Parsed event drawer is empty — the body could not decode as JSON (a proxy or a provider-side form-encoding issued it), so only the raw, PII-masked bytes survived. Decode the request’s content-type, and check the provider’s contract for the exact payload shape.
  • Error row persists — the record carries an error field when the handler captured a message (for example a malformed Meta verification payload). The error text is PII-masked the same way the body is.
  • Per-carrier quirks — SMPP bodies decoded from percent-encoded or foreign encodings can carry literal NUL bytes (a UTF-16BE receipt that ends in \x00); those bytes are stripped when the record is stored, so a record always persists even on a mangled payload. Meta sends statuses[] arrays and user_preferences blocks — the parsed drawer shows the whole decoded structure, so look for those nested entries, not a flat status field.
  • Row missing entirely — the inbound-webhook log is best-effort by design: the handler’s 2xx never blocks on it. If the platform-log table is absent on a fresh deploy, records stop silently; on a live tenant a missing row means a platform-side fault, so treat it as an anomaly and check with support rather than assume the callback never arrived.

Next steps