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 badgevalid, 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 backingGET /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.

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:
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. Examples of the fold:
  • Telnyx UNDLV / SMPP DELIVRD familymessage.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 softswitchDELIVRD 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-outsuser_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.

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