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

# Debug inbound carrier webhooks

> Inspect every inbound carrier webhook Orbit receives — signature status, raw body, parsed event — and fold Telnyx, SMPP, DIDWW, and Meta status vocabularies onto the canonical Orbit event your receiver subscribes to.

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

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

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/webhooks/inbound-events" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

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

```typescript theme={null}
import { Orbit } from '@devotel-orbit/node';
import { normalizeInboundEvent } from '@devotel/shared/webhooks/inbound-normalization';

app.post('/webhooks/orbit', (req, res) => {
  const event = Orbit.webhooks.constructEvent(
    req.rawBody,
    req.headers['x-orbit-signature'] as string,
    process.env.ORBIT_WEBHOOK_SECRET!,
  );

  switch (event.type) {
    case 'message.delivered':
      // carrier DLR received
      break;
    case 'message.read':
      // WhatsApp read receipt
      break;
    case 'message.failed':
      // terminal failure — the raw carrier string is on data.provider_status
      break;
    case 'contact.opted_out':
      // opt-out signal (STOP keyword or Meta preference)
      break;
    case 'message.received':
      // inbound MO message
      break;
    default:
      break;
  }

  // Validate a carrier status you saw in the parsed event drawer:
  const normalized = normalizeInboundEvent({
    raw_status: 'UNDLV',
    provider: 'devotel_softswitch',
  });
  // → { event: 'message.failed', status: 'failed', kind: 'failure', ... }

  res.status(200).json({ received: true });
});
```

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

* [Webhooks endpoints](/api-reference/endpoints/webhooks) — the fold-table API reference (the catalog lives under `/api/v1/webhooks/inbound-events`).
* [Message status map](/concepts/message-status-map) — the canonical status vocabulary every receiver branch uses.
* [First webhook quickstart](/guides/first-webhook-quickstart) — wire a receiver that consumes the canonical events this guide normalizes.
* [Verify webhook signatures with the SDK, per language](/guides/verify-webhook-signatures) — sign-in details for the receiver pairing above.
* [Event sources (Kafka inbound)](/guides/event-sources-inbound) — publish your own domain events into Orbit; the inbound filter vocabulary is the same in both directions.
