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, ormissing, against the provider’s signing scheme. A401on a forged POST still leaves aninvalidrow for forensics. - Raw body — the request bytes, truncated to 256 KiB per row, with phone numbers and emails masked at insert time.
?reveal=trueis 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-webhooksfor the list,GET /api/v1/developer/inbound-webhooks/:idfor 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.
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 isGET /api/v1/webhooks/inbound-events:
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/ SMPPDELIVRDfamily —message.sending_failed,sending_failed,delivery_failed,rejected,undeliveredfold ontomessage.failed(afailurekind,failedstatus). Telnyxmessage.sent,queued,sendingfold ontomessage.sent. - SMPP codes through the Devotel softswitch —
DELIVRDfold ontomessage.delivered;UNDLV,REJECTD,EXPIRED, andsubmitted_no_receiptfold ontomessage.failed. - Meta (WhatsApp / Messenger / Instagram)
readreceipt — thereadstatus folds ontomessage.read(adeliverykind). Adeliveredfrom the same channel folds ontomessage.delivered. - Meta opt-outs —
user_opt_out,marketing_opt_out, aSTOPkeyword, oropt_outfold ontocontact.opted_out(a canonical opt-out event,opted_outstatus). - Generic inbound (MO) —
mo,inbound,received,messagefold ontomessage.received(areceivedkind). The “a message arrived” branch is carrier-agnostic by construction.
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:
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
invalidormissing— the provider signature header is either absent or rejected. The allowlist keeps the provider’s own signature header (for exampletelnyx-signature-ed25519,x-meta-signature) on the record, so you can compare the exact header and timestamp the carrier sent. A401was 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
errorfield 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 sendsstatuses[]arrays anduser_preferencesblocks — 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
2xxnever 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
- Webhooks endpoints — the fold-table API reference (the catalog lives under
/api/v1/webhooks/inbound-events). - Message status map — the canonical status vocabulary every receiver branch uses.
- First webhook quickstart — wire a receiver that consumes the canonical events this guide normalizes.
- Verify webhook signatures with the SDK, per language — sign-in details for the receiver pairing above.
- Event sources (Kafka inbound) — publish your own domain events into Orbit; the inbound filter vocabulary is the same in both directions.