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

# Build a durable webhook consumer

> Build a production-grade Orbit webhook consumer: register an endpoint, verify HMAC signatures in Node.js or Python, dedupe events, ack fast, survive retries and dead letters, rotate secrets, and test locally.

# Build a durable webhook consumer

## What you'll build

A server that receives Orbit webhook events — message status (`message.delivered`, `message.failed`), call lifecycle (`call.completed`), and number-porting updates (`porting.request.*`) — and processes them durably:

* **Signature-verified** — every request is authenticated with HMAC-SHA256 before anything else happens.
* **Replayable** — seen event IDs are persisted, so the automatic retry loop can't double-process an event.
* **Fast to ack** — the handler returns `2xx` in milliseconds and hands the work to a queue instead of doing it inline.
* **Rotation-ready** — the verifier accepts the `X-Orbit-Signature-Next` grace header during a secret rotation, so rotation is a zero-downtime affair.

For endpoint concepts and the delivery contract, start at [Webhooks overview](/webhooks/overview). For the signature math itself, see [Webhook security](/webhooks/security). This guide puts those pieces together into a working consumer.

## Prerequisites

* A publicly reachable HTTPS URL you control, e.g. `https://yourapp.com/webhooks/orbit` (you'll tunnel to it for local testing in step 8).
* An Orbit API key (`dv_live_sk_...`) with webhook management permission — created in the dashboard under **Settings > API Keys**.
* The event names you care about from the [event catalog](/webhooks/events) — this guide uses `message.delivered`, `message.failed`, `call.completed`, and `porting.request.loa_signed`, but the pattern is identical for any event.

## Step 1 — Register the endpoint and capture the secret

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/orbit",
    "events": [
      "message.delivered",
      "message.failed",
      "call.completed",
      "porting.request.loa_signed"
    ]
  }'
```

The response includes your endpoint id (`wh_...`) and the full cleartext `whsec_...` signing secret. **Copy the secret now — it is returned exactly once.** Omitting `secret` in the body lets Orbit generate it, which is what this example does. A later `GET /api/v1/webhooks/{id}` returns only a masked `whsec_********...<last4>` preview; feed that masked value to your verifier and every signature check silently fails.

## Step 2 — Build the receiver

The receiver must read the **raw** request body (before any JSON parser touches it), check the signature timestamp (reject anything older than 5 minutes), and verify the HMAC before it does any work.

### Node.js (Express)

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

const app = express();

// Keep the raw body — HMAC is computed over the exact bytes received.
app.post(
  "/webhooks/orbit",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const header =
      req.headers["x-orbit-signature"] ?? req.headers["x-devotel-signature"];
    const raw = req.body; // Buffer of the raw payload

    if (!header || !verifySignature(raw, String(header), process.env.ORBIT_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: "Invalid signature" });
    }

    const eventId = getEventId(raw);
    if (!eventId) return res.status(400).json({ error: "Missing event id" });

    // Dedupe before any work — retries re-deliver the same id.
    if (!seenEvents.remember(eventId)) {
      return res.status(200).json({ received: true, duplicate: true });
    }

    // Ack fast, process later — never run business logic inline.
    queue.enqueue({ rawBody: raw.toString("utf8") });
    return res.status(200).json({ received: true });
  }
);

function getEventId(raw) {
  try {
    const body = JSON.parse(raw.toString("utf8"));
    return body.id;
  } catch {
    return null;
  }
}

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;

  // Replay protection — reject timestamps older than 5 minutes.
  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);
  });
}
```

Read the canonical `X-Orbit-Signature` header and fall back to the legacy `X-Devotel-Signature` so older in-flight deliveries still validate. During a [secret rotation](/webhooks/security) Orbit additionally signs with your previous secret and sends it as the `X-Orbit-Signature-Next` header — to accept either key, parse the rotation state from `GET /api/v1/webhooks/{id}/rotation/status` and check `X-Orbit-Signature-Next` against the previous secret when that header is present.

Queue jobs run the parsed payload against your business logic and can throw freely — a failed job just stays in your own queue for its own retry. The webhook handler itself never fails as long as parsing and enqueueing succeed.

### Python (Flask)

```python theme={null}
import os
import hmac
import hashlib
import time
import json

from flask import Flask, request, jsonify

app = Flask(__name__)
SECRET = os.environ["ORBIT_WEBHOOK_SECRET"]


@app.route("/webhooks/orbit", methods=["POST"])
def handle():
    header = request.headers.get("X-Orbit-Signature") or request.headers.get(
        "X-Devotel-Signature", ""
    )
    raw = request.get_data()  # raw bytes, untouched by any parser
    if not verify_signature(raw, header, SECRET):
        return jsonify({"error": "Invalid signature"}), 401

    try:
        event = json.loads(raw)
    except ValueError:
        return jsonify({"error": "Missing event id"}), 400

    event_id = event.get("id")
    if not event_id:
        return jsonify({"error": "Missing event id"}), 400

    # Dedupe before any work — retries re-deliver the same id.
    if not seen_events.remember(event_id):
        return jsonify({"received": True, "duplicate": True}), 200

    # Ack fast, process later.
    queue.enqueue(event_id=event_id, body=event)
    return jsonify({"received": True}), 200


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

    # Replay protection — reject timestamps older than 5 minutes.
    age = int(time.time()) - int(t)
    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)
```

## Step 3 — Persist and dedupe

Dedupe must survive process restarts, so keep the seen IDs in your database, not in memory — the examples above use a shared `seen_events` store whose implementation is one table:

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

`INSERT ... ON CONFLICT (event_id) DO NOTHING` returning zero rows means "already processed, ack as duplicate." Because Orbit retries a failing endpoint for about 4–5 hours and a dead-lettered event stays replayable for 7 days, keep these rows around for at least 7 days — prune them with a nightly `DELETE` against `first_seen < now() - interval '7 days'` (or your database's equivalent TTL mechanism).

Your worker then reads its own queue (Sidekiq, Celery, BullMQ, a plain table — whatever you already run) and hands the parsed event to the right handler:

```javascript theme={null}
switch (event.type) {
  case "message.delivered":
    return markMessageDelivered(event.data.message_id);
  case "message.failed":
    return flagMessageFailure(event.data.message_id, event.data.error_code);
  case "call.completed":
    return recordCallOutcome(event.data.call_id);
  case "porting.request.loa_signed":
    return advancePortingWorkflow(event.data);
}
```

## Step 4 — Understand retries and the dead letter queue

Orbit gives you at-least-once delivery: an event can arrive more than once, which is why the dedupe table is non-negotiable. If your endpoint returns a non-2xx or times out, Orbit retries with exponential backoff (30s → 60s → … doubling, up to 20% jitter) for one initial attempt plus nine retries, then moves the event to the dead letter queue — roughly 4.3 hours after the first attempt. From the dashboard under **Webhooks > Failed Deliveries** you can replay dead-lettered events for 7 days. Net effect: a failed endpoint has hours to catch up, and a fixed endpoint can replay everything it missed.

## Step 5 — Rotate signing secrets

Two rotation paths exist; use them in the order below.

The productised lifecycle is the preferred path. Initiate a rotation to mint a new candidate secret:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks/wh_abc123/rotation/initiate \
  -H "X-API-Key: dv_live_sk_..."
```

Copy the returned cleartext secret into your verifier alongside the current one (the response is the only place it is ever visible). During the 7-day grace window Orbit dual-signs: `X-Orbit-Signature` uses the new secret and `X-Orbit-Signature-Next` uses the previous one, so old and new verifiers both pass. Poll `GET /api/v1/webhooks/wh_abc123/rotation/status` until it reports the recent deliveries all verified with the new secret, then:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks/wh_abc123/rotation/complete \
  -H "X-API-Key: dv_live_sk_..."
```

Completion swaps the candidate in as the canonical secret. `POST .../rotation/cancel` discards the candidate if you need to abort. If you call initiate again within 24 hours you get the existing rotation back rather than a fresh secret, which keeps two dashboard tabs from invalidating each other.

If you lose the cleartext secret entirely, the legacy fallback still works:

```bash theme={null}
# Returns a new cleartext secret; the previous one is honored during a
# 7-day grace window so in-flight deliveries keep verifying.
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks/wh_abc123/rotate-secret \
  -H "X-API-Key: dv_live_sk_..."
```

## Step 6 — Test locally

Point a tunnel at your local server and register that URL as a temporary endpoint:

```bash theme={null}
ngrok http 3000
# or
npx localtunnel --port 3000
```

Then push a synthetic event at it with your signing secret:

```bash theme={null}
SECRET="whsec_your_test_secret"
BODY='{"id":"evt_test_local","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"
```

The same one-liner signs a replayed payload against either the canonical or the grace header; send the same body twice to confirm the second call acks as `duplicate: true`.

## Production checklist

* [ ] Endpoint URL is HTTPS (required for delivery).
* [ ] Handler acks within the 30-second timeout window — heavy work happens in a queue, past-30s responses count as failures.
* [ ] Signature verification happens before any other request handling, with timing-safe comparison and the 5-minute timestamp window.
* [ ] Dedupe IDs persist for at least 7 days to cover retry + dead-letter replay windows.
* [ ] If your firewall restricts inbound HTTPS, allowlist every IP returned by `GET /api/v1/webhooks/egress-ips` — and treat that as a second factor, never a replacement for signature verification.
* [ ] Monitor failed deliveries in **Webhooks > Failed Deliveries**; alert on any event reaching the dead letter queue.
* [ ] Store the signing secret in a secrets manager, never in code or logs.

## Next steps

* [Webhook events reference](/webhooks/events) — subscribe to additional events
* [Event payloads](/webhooks/event-payloads) — per-event schema reference
* [Webhook security](/webhooks/security) — signature details and IP allowlisting
