> ## 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 your first webhook receiver

> End-to-end walkthrough: stand up a POST endpoint, verify the X-Orbit-Signature HMAC, deduplicate by event id, route event types to handlers, and handle retries and the dead-letter queue.

# Build your first webhook receiver

This walkthrough takes you from zero to your first verified Orbit delivery. By the end you have a receiver that verifies the signature, deduplicates retries, and routes each event type to a handler — plus the retry semantics to reason about it in production.

The same pieces exist deeper in the reference docs; this page puts them in build order. [Security](/webhooks/security) is the canonical signature reference with verifiers in seven languages, [Event payloads](/webhooks/event-payloads) documents the envelope and headers, and [Inspecting deliveries](/webhooks/inspecting-deliveries) covers replay tooling.

## Step 1 — Register an endpoint and copy the secret

Register a webhook subscription in the dashboard under **Settings → Webhooks → Add endpoint**, or over the API:

```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"]
  }'
```

The response includes the cleartext signing secret (`whsec_...`). This is the **only** time it is shown — copy it immediately and store it as an environment variable, never in code. Subscribe to a narrow event list while you develop; broad `*` subscriptions generate far more traffic than a new integration needs.

## Step 2 — Serve a POST endpoint with a raw body

Verification signs the **raw request body**, so your receiver must read the body before any JSON middleware parses it. Keep the raw bytes around.

Node (Express — register `express.raw()` on this route before any global `express.json()`):

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

const app = express();

const seen = new Set(); // replace with durable storage (Redis / a DB table) in production

function parseSignatureHeader(header) {
  const parts = header.split(',').map((p) => p.trim());
  let t = null;
  const v1s = [];
  for (const part of parts) {
    const eq = part.indexOf('=');
    if (eq < 0) continue;
    const key = part.slice(0, eq);
    const value = part.slice(eq + 1);
    if (key === 't') t = value;
    else if (key === 'v1') v1s.push(value);
  }
  return { t, v1s };
}

function verifySignature(rawBody, header, secret) {
  if (!header) return false;
  const { t, v1s } = parseSignatureHeader(header);
  if (!t || v1s.length === 0) return false;

  // Replay protection: reject signatures older than 5 minutes.
  const ageSec = Math.floor(Date.now() / 1000) - Number(t);
  if (!Number.isFinite(ageSec) || ageSec < 0 || ageSec > 5 * 60) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${t}.${rawBody}`, 'utf-8')
    .digest('hex');
  const expectedBuf = Buffer.from(expected, 'hex');

  return v1s.some((candidate) => {
    const candidateBuf = Buffer.from(candidate, 'hex');
    return (
      candidateBuf.length === expectedBuf.length &&
      crypto.timingSafeEqual(candidateBuf, expectedBuf)
    );
  });
}

app.post('/webhooks/orbit', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf-8');

  // Step 3 — verify the signature
  const header = req.headers['x-orbit-signature'] ?? req.headers['x-devotel-signature'];
  if (!verifySignature(rawBody, header, process.env.ORBIT_WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  const event = JSON.parse(rawBody);

  // Step 4 — dedup by event id
  if (seen.has(event.id)) {
    return res.status(200).json({ received: true, duplicate: true });
  }
  seen.add(event.id);

  // Step 5 — route by event type
  switch (event.type) {
    case 'message.delivered':
      // mark the message delivered in your system
      break;
    case 'message.failed':
      // alert, queue a retry, or notify the sender
      break;
    default:
      // acknowledge unknown types so new events don't jam retries
      break;
  }

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

app.listen(3000);
```

Python (Flask):

```python theme={null}
import hmac
import hashlib
import os
import time
from flask import Flask, jsonify, request

app = Flask(__name__)
seen = set()  # replace with durable storage (Redis / a DB table) in production


def parse_signature_header(header: str):
    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)
    return t, v1s


def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header:
        return False
    t, v1s = parse_signature_header(header)
    if not t or not v1s:
        return False

    # Replay protection: reject signatures 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)


@app.route("/webhooks/orbit", methods=["POST"])
def handle_webhook():
    # Step 3 — verify the signature
    header = request.headers.get("X-Orbit-Signature") or request.headers.get(
        "X-Devotel-Signature", ""
    )
    if not verify_signature(request.data, header, os.environ["ORBIT_WEBHOOK_SECRET"]):
        return jsonify({"error": "Invalid signature"}), 401

    event = request.get_json()

    # Step 4 — dedup by event id
    if event["id"] in seen:
        return jsonify({"received": True, "duplicate": True}), 200
    seen.add(event["id"])

    # Step 5 — route by event type
    if event["type"] == "message.delivered":
        pass  # mark the message delivered in your system
    elif event["type"] == "message.failed":
        pass  # alert, queue a retry, or notify the sender

    return jsonify({"received": True}), 200
```

<Note>
  The Python SDK ships the same verifier as `orbit_sdk.verify_webhook(payload, signature, secret)` — one call that raises `OrbitWebhookSignatureError` on failure and returns the decoded event dict. The Node verifier above is the same logic with no dependency. Verifiers in Go, Ruby, PHP, Java, and C# are on [Webhook security](/webhooks/security).
</Note>

## Step 3 — Verify the signature (shown above)

Both receivers read the canonical `X-Orbit-Signature` header and fall back to the legacy `X-Devotel-Signature`, parse the `t=<unix>,v1=<hex>` format, reject timestamps older than 5 minutes, and compare `HMAC_SHA256(secret, "<t>.<raw_body>")` with a timing-safe comparison. During a secret-rotation grace window the legacy header carries two `v1` candidates — the "accept if any matches" logic above handles that without changes.

Return `401` on failure and stop — never process an unverified payload.

## Step 4 — Deduplicate by event id

Delivery is at-least-once: a timeout or retry can deliver the same event twice. The envelope `id` (`evt_...`) is stable across every retry of one event, and the `Idempotency-Key` header mirrors it. Persist every id you process and return `200` immediately for a duplicate — a fast duplicate ack is better than re-firing billable work downstream.

Keep ids for at least 7 days, which covers the full retry window plus the dead-letter replay window.

## Step 5 — Map event types to handlers

Route on `event.type` and acknowledge types you do not handle. New event types appear over time; a receiver that returns 4xx on an unknown type burns its retry budget on events it never wanted. The full catalog of type values is on [Webhook events](/webhooks/events).

## Retry semantics — the concrete timings

If your endpoint returns a non-2xx response or does not answer within **30 seconds**, Orbit retries the delivery on an exponential backoff schedule: one initial delivery plus 9 retries (10 attempts total). The backoff starts at 30 seconds and doubles each retry:

| Attempt         | 1 | 2    | 3    | 4     | 5     | 6     | 7     | 8       | 9       | 10      |
| --------------- | - | ---- | ---- | ----- | ----- | ----- | ----- | ------- | ------- | ------- |
| Delay before it | — | 30 s | 60 s | 120 s | 240 s | 480 s | 960 s | 1,920 s | 3,840 s | 7,680 s |

Up to 20% extra jitter is applied on top, so a burst of failures against one endpoint does not retry in lockstep. The whole schedule spans roughly 4.3 hours — after the 10th attempt fails, the event moves to the dead-letter queue.

Response codes mean specific things:

* Any `2xx` within 30 seconds — success, no retry.
* Timeout or `5xx` — always retries.
* Retryable `4xx` (400, 422, ...) — retries until the schedule is exhausted.
* `401`, `403`, `404`, or `410 Gone` — proven-dead: skips retries, moves to the dead-letter queue, and immediately **disables the endpoint** (the org admin is notified). `410 Gone` is also how you permanently skip retries for a lone event type you have deprecated.

50 consecutive failures on one endpoint also auto-disables it. Re-enable from the dashboard once your receiver is healthy; deliveries to a disabled endpoint go straight to the dead-letter queue.

## Dead-letter queue and replay

After retries exhaust, events sit in the dead-letter queue for **7 days**, replayable from the dashboard under **Developer → Webhooks → Dead-letter queue** or over the API:

```bash theme={null}
# List dead-lettered deliveries
curl https://api.orbit.devotel.io/api/v1/webhooks/dlq \
  -H "X-API-Key: dv_live_sk_..."

# Requeue one delivery
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks/dlq/dlv_abc123/requeue \
  -H "X-API-Key: dv_live_sk_..."
```

For a single failed delivery from the event timeline, use **Replay** on the delivery row, or `POST /api/v1/webhooks/{endpoint_id}/deliveries/{delivery_id}/replay` — failed-only, same endpoint, within 7 days. The dashboard flow and bulk paths are on [Inspecting deliveries](/webhooks/inspecting-deliveries).

## Test the loop

1. Run the receiver locally and expose it with a tunnel (`ngrok http 3000`).
2. Register the tunnel URL, subscribe to one event type, and copy the cleartext secret.
3. Trigger a real event (send a test SMS) and watch the signature verify, the handler run, and a `200` go back.
4. Flip the receiver off and re-trigger — watch the retry schedule in **Developer → Webhooks → Events**, then bring the receiver back and replay the failure.

If signatures fail to verify, work through [Troubleshooting: Signature Verification Failures](/webhooks/troubleshooting-signature-failures) — the common case is a JSON middleware consuming the raw body before verification.

## See also

* [Webhooks overview](/webhooks/overview) — delivery guarantees, retry schedule, managing endpoints
* [Webhook security](/webhooks/security) — canonical HMAC reference, seven-language verifiers, key rotation
* [Webhook event payloads](/webhooks/event-payloads) — envelope schema and every request header
* [Inspecting deliveries and replaying failures](/webhooks/inspecting-deliveries) — events explorer, delivery inspector, bulk replay
* [Webhook events catalog](/webhooks/events) — every `type` value you can subscribe to
