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

# Production-grade webhook consumer: end-to-end walkthrough

> Build a complete production webhook consumer in one guided walkthrough — event catalog, HMAC signature verification in three languages, the ack-fast consumer loop, retry and dead-letter semantics, failure paths, idempotency, testing with the Webhook Tester, and a full worked example for campaign.delivered.

# Production-grade webhook consumer: end-to-end walkthrough

This guide ties together every piece you need to build a production-grade Orbit webhook consumer — from picking events to surviving retries — into one end-to-end narrative. The individual guides cover each piece in depth; this page is the orchestrated walkthrough that connects them.

## 1. Why signature verification is non-negotiable

Every Orbit webhook delivery carries an HMAC-SHA256 signature in the `X-Orbit-Signature` header. Without verifying it, your receiver treats every POST as genuine — which means it will process forged events, replay old events an attacker captured, and accept bodies mutated by a proxy between Orbit and your server.

The signature prevents two classes of attack:

* **Replay** — an attacker replays a captured delivery. The signature's embedded Unix timestamp (`t=`) plus your server's 5-minute clock skew check reject anything stale.
* **Forgery** — an attacker invents an event envelope. Without your `whsec_...` signing secret — which never leaves your control — the HMAC does not match.

One shipped pattern per language is all you need. The Node.js verifier below is the same one the [durable consumer guide](/guides/webhook-consumer) embeds; the Python and Go samples mirror the [polyglot signature guide](/guides/webhook-signature-verify-polyglot).

### Node.js (Express)

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

function verifySignature(rawBody, header, secret) {
  let t = null;
  const v1s = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") t = value;
    else if (key === "v1") v1s.push(value);
  }
  if (!t || v1s.length === 0) return false;

  const age = Math.floor(Date.now() / 1000) - Number(t);
  if (!Number.isFinite(age) || age < 0 || age > 5 * 60) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody.toString("utf8")}`, "utf8")
    .digest("hex");

  return v1s.some((candidate) => {
    const a = Buffer.from(candidate, "hex");
    const b = Buffer.from(expected, "hex");
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}
```

### Python (stdlib `hmac` + `hashlib`)

```python theme={null}
import hashlib
import hmac
import time
from typing import Optional

def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    t = None
    v1s = []
    for part in header.split(","):
        part = part.strip()
        if "=" not in part:
            continue
        key, _, value = part.partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            v1s.append(value)
    if not t or not v1s:
        return False

    try:
        age = int(time.time()) - int(t)
    except ValueError:
        return False
    if age < 0 or age > 5 * 60:
        return False

    signed = f"{t}.".encode("utf-8") + raw_body
    expected = hmac.new(secret.encode("utf-8"), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(candidate, expected) for candidate in v1s)
```

### Go (`crypto/hmac`)

```go theme={null}
import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "strconv"
    "strings"
    "time"
)

func parseSignatureHeader(header string) (string, []string) {
    var t string
    var v1s []string
    for _, part := range strings.Split(header, ",") {
        part = strings.TrimSpace(part)
        eq := strings.IndexByte(part, '=')
        if eq < 0 { continue }
        key, value := part[:eq], part[eq+1:]
        switch key {
        case "t": t = value
        case "v1": v1s = append(v1s, value)
        }
    }
    return t, v1s
}

func VerifySignature(rawBody []byte, header, secret string) bool {
    t, v1s := parseSignatureHeader(header)
    if t == "" || len(v1s) == 0 { return false }
    ts, err := strconv.ParseInt(t, 10, 64)
    if err != nil { return false }
    if age := time.Now().Unix() - ts; age < 0 || age > 5*60 { return false }

    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write([]byte(t + "."))
    mac.Write(rawBody)
    expected := hex.EncodeToString(mac.Sum(nil))
    for _, candidate := range v1s {
        if hmac.Equal([]byte(candidate), []byte(expected)) { return true }
    }
    return false
}
```

### The header fallback order

Read headers in this priority order — canonical first, then the rotation grace header, then the legacy back-compat header:

1. `X-Orbit-Signature` — signed with your current secret.
2. `X-Orbit-Signature-Next` — only present during a secret rotation grace window (7 days), signed with your previous secret.
3. `X-Devotel-Signature` — legacy header that carries one or two `v1=` candidates for older in-flight deliveries.

The polyglot guide covers Ruby and PHP verifiers as well, and includes negative tests that prove each verifier rejects a wrong secret. See [Verify webhook signatures without the SDK](/guides/webhook-signature-verify-polyglot) for the complete set.

## 2. Event catalog: what the envelope always carries

Every event Orbit dispatches shares a common envelope. Before you write a single handler, know the fields that every event type guarantees:

| Field | Type | Always present | Meaning |
| - | - | - | - |
| `id` | string | Yes | Stable deduplication key — the same event retried carries the same `id`. |
| `type` | string | Yes | The canonical event name you subscribed to, e.g. `message.delivered`. |
| `created_at` | string (ISO 8601) | Yes | When the event was emitted, not when it was delivered. |
| `data` | object | Yes | Event-specific payload — the shape varies by `type`. |
| `tenant` | string | Platform-internal | The tenant the event belongs to. Your receiver does not need to inspect this; it is the dispatcher's routing field. |

These fields are the contract the [event catalog](/guides/webhook-event-catalog) publishes. Every entry in the catalog carries a machine-readable `json_schema` (JSON Schema Draft-07) that asserts `required: ["id", "type", "created_at", "data"]` and pins `type` with a `const`, plus an `example_payload` you can copy into a fixture, and a `fingerprint` (`sha256:<16 hex>`) you pin in CI to catch schema drift. The catalog answers "what events exist" and "what shape is each payload" before any receiver code runs. Browse it in the dashboard under **Developer → Webhooks → Event catalog** or fetch it over the API:

```bash theme={null}
curl -s https://api.orbit.devotel.io/api/v1/webhooks/event-schemas \
  -H "X-API-Key: dv_live_sk_..." \
| jq '.data[] | select(.event_type == "message.delivered")'
```

Output:

```json theme={null}
{
  "event_type": "message.delivered",
  "json_schema": {
    "$schema": "http://json-schema.org/draft-07/schema#",
    "type": "object",
    "required": ["id", "type", "created_at", "data"],
    "properties": {
      "id": { "type": "string" },
      "type": { "type": "string", "const": "message.delivered" },
      "created_at": { "type": "string", "format": "date-time" },
      "data": { "type": "object", "required": ["message_id", "channel", "status"] }
    },
    "additionalProperties": false
  },
  "example_payload": {
    "id": "evt_d4e5f6g7h8",
    "type": "message.delivered",
    "created_at": "2026-03-08T14:30:05Z",
    "data": {
      "message_id": "msg_abc123",
      "channel": "sms",
      "to": "+14155552671",
      "status": "delivered",
      "delivered_at": "2026-03-08T14:30:05Z"
    }
  },
  "fingerprint": "sha256:4f2a1c8e9b3d7f0a"
}
```

Pin the fingerprint in CI so a schema change breaks the build before it corrupts a live receiver. The [event catalog guide](/guides/webhook-event-catalog) includes a complete CI script that does exactly that.

For the full per-event-family payload reference — with copy-pasteable JSON for all nine families (message lifecycle, voice, porting, verify, CDP, delivery receipts, campaign, flows, number lifecycle) — see the [payload cookbook](/guides/webhook-payload-cookbook).

## 3. The consumer loop: receive, verify, route, enqueue, ack

A production webhook consumer follows one fixed order. Skipping or reordering any step creates a defect that ships silently.

```
POST /your-webhook-handler
  │
  ├─ 1. Read raw body bytes (before any JSON parser touches them)
  │
  ├─ 2. Verify HMAC signature (section 1 above)
  │    ├─ Mismatch → 401, stop
  │    └─ Match → continue
  │
  ├─ 3. Parse JSON envelope → extract id, type, data
  │
  ├─ 4. Dedupe on event id (section 5 below)
  │    ├─ Seen → 200 { duplicate: true }, stop
  │    └─ New → persist id, continue
  │
  ├─ 5. Enqueue the parsed event (never run business logic inline)
  │
  └─ 6. Return 200 immediately (ack fast)
```

The handler itself never does heavy work — it verifies, dedupes, enqueues, and acks. Business logic runs in the queue worker, where it can throw freely without affecting the ack window.

### Complete handler (Node.js + Express)

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

const app = express();
const SECRET = process.env.ORBIT_WEBHOOK_SECRET;

app.post(
  "/webhooks/orbit",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header =
      req.headers["x-orbit-signature"] ??
      req.headers["x-orbit-signature-next"] ??
      req.headers["x-devotel-signature"];

    if (!header || !verifySignature(req.body, String(header), SECRET)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const envelope = safeParse(req.body);
    if (!envelope) {
      return res.status(400).json({ error: "Malformed envelope" });
    }

    const { id, type, data } = envelope;
    if (!id || !type) {
      return res.status(400).json({ error: "Missing required envelope fields" });
    }

    // Dedupe before any work.
    if (!seenEvents.remember(id)) {
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Ack fast, process later.
    queue.enqueue({ id, type, data });
    return res.status(200).json({ received: true });
  },
);

function safeParse(raw) {
  try { return JSON.parse(raw.toString("utf8")); }
  catch { return null; }
}
```

See [Build your first webhook receiver](/webhooks/first-receiver) for the tunnel-local-test walkthrough and the registration step.

## 4. Retry semantics and acknowledgment rules

Orbit delivers at-least-once. If your endpoint returns a non-2xx status or times out, the dispatcher retries with exponential backoff — 30s, 60s, 120s, and so on, doubling each round with up to 20% jitter — for one initial attempt plus up to nine retries. After the ladder is exhausted (roughly 4.3 hours from the first attempt), the event lands in the dead-letter queue.

### What to return per outcome

| Outcome | Status code | What Orbit does |
| - | - | - |
| Event accepted and queued | `200` (or any 2xx) | Delivery marked successful. No retry. |
| Signature mismatch | `401` | Counted as a failure. Retried per the ladder. Fix your secret or verifier. |
| Malformed payload (missing `id`, unparseable JSON) | `400` | Counted as a failure. Fix your parser — this never auto-resolves. |
| Server error (your code threw, your DB is down) | `500` (or any 5xx) | Counted as a failure. Retried per the ladder. |
| Timeout (your handler did not respond in time) | — | Counted as a failure. Increase your delivery timeout or move work to a queue. |

**Never return 2xx for a signature mismatch.** A 200 on an unauthenticated request tells Orbit the event was accepted — the retry loop stops and you lose the event. Always 401.

### Delivery timeout

Per-endpoint delivery timeout is configurable from 1 to 30 seconds (default 30). Set it in the endpoint's Edit dialog under **Developer → Webhooks** or through the API on `timeout_seconds`. If your handler does heavy work inline instead of enqueueing, a default 30s timeout masks the problem until traffic grows — keep handler work under one second and let the queue absorb the rest.

### Dead-letter replay

From **Developer → Webhooks → Failed Deliveries** you can replay dead-lettered events one at a time or in bulk for up to 7 days. After 7 days, dead-lettered events are removed. The [endpoint operations guide](/guides/webhook-endpoint-operations) walks bulk replay, dry-run preview, and polling the replay job to completion. Keep your dedupe table rows for at least 7 days to cover this window.

## 5. Failure paths

### Signature mismatch — 400 with a log, never 2xx

A signature check that fails is either a misconfigured secret in your verifier, a mutated raw body (a JSON middleware ran before verification), or a genuine forgery. Return `401` and log the failure with enough detail to diagnose:

```javascript theme={null}
if (!verifySignature(req.body, header, SECRET)) {
  console.warn("webhook signature mismatch", {
    hasHeader: !!header,
    headerSource: header ? header.substring(0, 20) + "..." : "missing",
    bodyLength: req.body.length,
    timestamp: header ? extractTimestamp(header) : null,
  });
  return res.status(401).json({ error: "Invalid signature" });
}
```

### Delivery timeout

If your handler does not respond within the configured timeout window, Orbit counts it as a failure and retries. The common cause is business logic running inline — every millisecond of DB work or API call in the handler is time your ack is not arriving. Move all business logic to a queue worker.

### Duplicate delivery (replay)

The same event `id` can arrive more than once — the retry loop re-POSTs identical payloads. Your dedupe table (section 6) is the only defense. Without it, a `message.delivered` retried 9 times updates the delivery state 10 times, and one of those might overwrite a later status with a stale one.

For idempotency patterns that survive beyond the 7-day dedupe window — including safe retries you initiate from your own queue — see the [idempotency and safe retries](/concepts/idempotency-and-safe-retries) concept page.

## 6. Deduplication and the 7-day window

The dedupe key is the envelope `id`. It is stable across retries of the same event, and it is what Orbit retries by. Persist seen ids in your database — not in memory — so deduplication survives process restarts:

```sql theme={null}
CREATE TABLE seen_webhook_events (
  event_id   TEXT PRIMARY KEY,
  first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

On each delivery:

```javascript theme={null}
function remember(eventId) {
  const result = db.exec(
    "INSERT INTO seen_webhook_events (event_id) VALUES (?) ON CONFLICT (event_id) DO NOTHING",
    [eventId],
  );
  return result.rowsAffected > 0; // true = new, false = duplicate
}
```

Prune rows older than 7 days with a nightly `DELETE ... WHERE first_seen < now() - interval '7 days'` so the table does not grow unbounded.

## 7. Testing with the Webhook Tester and the inbound debugger

The [Webhook Tester](/guides/webhook-tester) (dashboard at **Developer → Webhook Tester**) fires synthetic events at any HTTPS URL and shows your server's real status, headers, and response body. Use it during development to iterate on payload parsing before you register a real endpoint.

Two facts to know:

* **Test sends are unsigned.** The tester has no endpoint secret, so no `X-Orbit-Signature` header is attached. A verification-first receiver correctly returns 401. To exercise the signature path, use the signed **Test-fire** action on a registered endpoint under **Settings → Webhooks**.
* **The tester delivers through the real dispatcher.** The POST reaching your server is genuine — your URL must be publicly reachable over HTTPS.

For debugging carrier-side signatures — verifying that inbound carrier webhooks (Telnyx, SMPP, DIDWW, Meta) are authentic before they fan out to your endpoint — see [Debug inbound carrier webhooks](/guides/inbound-webhook-debugging). The inbound debugger records every callback from the moment it arrives, before signature verification, so even rejected requests leave a row you can inspect.

### Local test with a signed payload

Once you have registered an endpoint and captured the `whsec_...` secret, sign a payload locally to confirm your verifier works:

```bash theme={null}
SECRET="whsec_your_test_secret"
BODY='{"id":"evt_test","type":"message.delivered","created_at":"2026-08-23T12:00:00Z","data":{"message_id":"msg_test","status":"delivered"}}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST http://localhost:3000/webhooks/orbit \
  -H "Content-Type: application/json" \
  -H "X-Orbit-Signature: t=$T,v1=$SIG" \
  -d "$BODY"
```

Send the same body twice — the second call should ack as `duplicate: true`.

## 8. Worked example: a `campaign.delivered` consumer

Here is a complete consumer wired for `campaign.delivered`. It reads the raw body, verifies the signature, dedupes on event `id`, enqueues the parsed payload, and acks in under a millisecond.

### The envelope

```json theme={null}
{
  "id": "evt_cmp_dlv_4f1b",
  "type": "campaign.delivered",
  "created_at": "2026-10-09T09:00:03Z",
  "data": {
    "campaign_id": "camp_4f1c8e",
    "message_id": "msg_abc123",
    "contact_id": "con_xyz789",
    "channel": "sms",
    "delivered_at": "2026-10-09T09:00:03Z"
  }
}
```

### Queue worker

The handler acks in microseconds. The queue worker processes the event when it is ready:

```javascript theme={null}
// queue worker — runs in its own process, can throw freely
switch (event.type) {
  case "campaign.delivered":
    await markCampaignMessageDelivered(
      event.data.campaign_id,
      event.data.message_id,
      event.data.contact_id,
      event.data.delivered_at,
    );
    break;

  case "message.delivered":
    await updateDeliveryState(event.data.message_id, "delivered");
    break;

  case "message.failed":
    await flagDeliveryFailure(event.data.message_id, event.data.error_code);
    break;

  default:
    logger.warn("unhandled event type", { type: event.type, id: event.id });
}
```

The `campaign.delivered` handler records the delivery against the campaign's analytics and the contact's timeline. If the DB write fails, your queue retries the job — the webhook ack already returned 200, so Orbit does not re-deliver.

## 9. Secure storage of the signing secret and rotation

The `whsec_...` secret controls who can forge a valid signature. Treat it with the same care as an API key:

* Store it in a secrets manager (environment variable, vault, SSM parameter) — never in code, config files, or logs.
* The cleartext secret is returned exactly once: at endpoint creation and at rotation initiation/completion. Every later `GET /api/v1/webhooks/{id}` returns a masked `whsec_********...<last4>` preview.
* If you lose the cleartext secret, run a rotation to mint a new one — you cannot recover a lost secret.

### Rotation in the console

Open the endpoint in **Settings → Webhooks**, then open **Rotate secret** from the detail page or overflow menu. The wizard mints a new candidate secret while the old one keeps verifying deliveries. During the 7-day grace window every delivery carries both `X-Orbit-Signature` (signed with the new secret) and `X-Orbit-Signature-Next` (signed with the previous one). Copy the new secret into your verifier alongside the current one, confirm, and when the status panel shows most deliveries verify with the new secret, click **Complete rotation** to retire the old one.

The [endpoint operations guide](/guides/webhook-endpoint-operations) and the [edit console guide](/guides/webhook-edit-console) walk each rotation step with screenshots, force-complete, and the `POST /api/v1/webhooks/{id}/rotation/initiate` API workflow.

## Next steps

* [Build a durable webhook consumer](/guides/webhook-consumer) — Node.js and Python handlers with dedup, retry, and rotation in detail
* [Explore webhook event schemas from the catalog](/guides/webhook-event-catalog) — machine-readable schemas, CI fingerprint pins, and the TypeScript codegen path
* [Verify webhook signatures without the SDK](/guides/webhook-signature-verify-polyglot) — standard-library verifiers for Python, Go, Ruby, and PHP with negative tests
* [Webhook payload cookbook](/guides/webhook-payload-cookbook) — one copy-pasteable JSON payload per event family
* [Test webhooks with the Webhook Tester](/guides/webhook-tester) — unsigned test sends and the signed Test-fire path
* [Operate a webhook endpoint](/guides/webhook-endpoint-operations) — health, replay, bulk replay, pause, and the DLQ
* [Debug inbound carrier webhooks](/guides/inbound-webhook-debugging) — carrier-side signature verification and the inbound debug log
* [Quickstart: receive your first webhook](/webhooks/first-receiver) — tunnel, register, verify, replay end to end
* [Webhook security](/webhooks/security) — HMAC format specification and the rotation grace header


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.