> ## Documentation Index
> Fetch the complete documentation index at: https://docs.optimaldial.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Receiving webhooks

> Build a production-grade receiver for OptimalDial webhooks — payload shape, signature verification, retry behaviour, and best practices.

Webhooks are HTTP POST requests OptimalDial sends to a URL you control whenever something interesting happens to one of your uploads. They're the right way to learn about state changes — polling works, but webhooks arrive within seconds and don't burn rate-limit budget.

This guide covers everything on the receiving side: what we send, how to verify it, how we retry, and the small set of decisions that separate a brittle integration from a sturdy one. For the *configuration* endpoints (registering URLs, listing deliveries) see the [Webhooks reference](/api-reference/webhooks).

## Event types

| Event              | When it fires                                                                                                                                                                 | Notable payload fields                                                                      |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `upload.created`   | A new upload row is inserted. Fires for both API and web sources.                                                                                                             | `data.upload.source` ∈ {`"web"`, `"api"`}                                                   |
| `upload.completed` | Processing finishes successfully.                                                                                                                                             | `data.upload.downloads.processed`, `data.upload.downloads.processed_filtered` (signed URLs) |
| `upload.failed`    | Processing fails.                                                                                                                                                             | `data.error_message`                                                                        |
| `spam.detected`    | A monitored number transitions clean → spam on a carrier. **Edge-triggered** — fires once per carrier on the transition, *not* on every daily re-test while it stays flagged. | `data.carrier`, `data.spam_label`, `data.sip_response_code`, `data.same_carrier_warning`    |
| `number.verified`  | A [monitored number](/api-reference/spam-monitoring) completes verification.                                                                                                  | `data.carrier`, `data.verified_at`                                                          |
| `webhook.ping`     | Sent once at endpoint registration to verify reachability.                                                                                                                    | `data.challenge`                                                                            |

Pings are sent only during `POST /api/v1/webhooks` and `PATCH /api/v1/webhooks/{id}` (when the URL changes). They are never re-sent later — your receiver must be live before you register.

## Event envelope

Every event has the same outer shape. The `data` object is event-specific.

```json theme={null}
{
  "id": "evt_a1b2c3d4e5f67890abcdef12",
  "type": "upload.completed",
  "api_version": "2026-04-23",
  "created_at": "2026-04-24T12:34:56.789+00:00",
  "data": {
    "upload": {
      "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
      "source": "api",
      "status": "completed",
      "original_filename": "april-cohort.csv",
      "valid_row_count": 12000,
      "valid_number_count": 12000,
      "invalid_row_count": 37,
      "organization_id": "01HABCD…",
      "created_at": "2026-04-24T12:30:00+00:00",
      "downloads": {
        "processed": "https://storage.example.com/...?signed=...",
        "processed_filtered": "https://storage.example.com/...?signed=..."
      }
    }
  }
}
```

Sample bodies for each event type:

<Tabs>
  <Tab title="upload.created">
    ```json theme={null}
    {
      "id": "evt_d4e5f67890abcdef12345678",
      "type": "upload.created",
      "api_version": "2026-04-23",
      "created_at": "2026-04-24T12:30:00+00:00",
      "data": {
        "upload": {
          "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
          "source": "api",
          "status": "ready_for_processing",
          "original_filename": "april-cohort.csv",
          "total_rows": 12037,
          "valid_row_count": 12000,
          "valid_number_count": 12000,
          "organization_id": "01HABCD…",
          "created_at": "2026-04-24T12:30:00+00:00"
        }
      }
    }
    ```

    Note: the `created` payload has `total_rows` (everything you submitted), `valid_row_count` (contacts that will be processed), and `valid_number_count` (the phone numbers you were charged for). On an upload that maps [several phone columns](/api-reference/uploads#multiple-phone-columns) the last two differ, so `total_rows - valid_row_count` is not an invalid count. It does **not** include download URLs — those only appear once processing finishes.
  </Tab>

  <Tab title="upload.completed">
    ```json theme={null}
    {
      "id": "evt_a1b2c3d4e5f67890abcdef12",
      "type": "upload.completed",
      "api_version": "2026-04-23",
      "created_at": "2026-04-24T12:36:42+00:00",
      "data": {
        "upload": {
          "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
          "source": "api",
          "status": "completed",
          "original_filename": "april-cohort.csv",
          "valid_row_count": 12000,
          "valid_number_count": 12000,
          "invalid_row_count": 37,
          "organization_id": "01HABCD…",
          "created_at": "2026-04-24T12:30:00+00:00",
          "downloads": {
            "processed": "https://storage.example.com/...?signed=...",
            "processed_filtered": "https://storage.example.com/...?signed=..."
          }
        }
      }
    }
    ```

    The signed download URLs in `downloads.*` are short-lived. Fetch them quickly, or call the [download endpoints](/api-reference/uploads#download-endpoints) when you need a fresh URL.
  </Tab>

  <Tab title="upload.failed">
    ```json theme={null}
    {
      "id": "evt_b2c3d4e5f67890abcdef1234",
      "type": "upload.failed",
      "api_version": "2026-04-23",
      "created_at": "2026-04-24T12:40:00+00:00",
      "data": {
        "upload": {
          "id": "f3c9b8a7-1234-4abc-9def-0123456789ab",
          "source": "api",
          "status": "failed",
          "original_filename": "april-cohort.csv",
          "valid_row_count": 12000,
          "valid_number_count": 12000,
          "invalid_row_count": 37,
          "organization_id": "01HABCD…",
          "created_at": "2026-04-24T12:30:00+00:00",
          "downloads": null
        },
        "error_message": "Carrier lookup provider timed out after 3 retries"
      }
    }
    ```
  </Tab>

  <Tab title="spam.detected">
    ```json theme={null}
    {
      "id": "evt_c3d4e5f67890abcdef123456",
      "type": "spam.detected",
      "api_version": "2026-04-23",
      "created_at": "2026-06-26T15:08:55+00:00",
      "data": {
        "number_id": "7c2a1f0e-9b3d-4a8e-bf21-0c5d6e7f8a90",
        "phone_number": "+15551234567",
        "carrier": "t-mobile",
        "spam_label": "Scam Likely",
        "sip_response_code": "200",
        "caller_id_verified": false,
        "same_carrier_warning": false,
        "test_run_id": "run_8f1d…",
        "screenshot_available": true,
        "detected_at": "2026-06-26T15:08:55+00:00"
      }
    }
    ```

    **Edge-triggered.** This fires once when a carrier flips a number from clean to flagged. It will **not** fire again for the same carrier on subsequent daily re-tests while the number stays flagged — only on the clean→spam transition. Pull `GET /api/v1/spam/numbers/{id}/screenshot?carrier=t-mobile` for the captured evidence.
  </Tab>

  <Tab title="number.verified">
    ```json theme={null}
    {
      "id": "evt_e5f67890abcdef1234567890",
      "type": "number.verified",
      "api_version": "2026-04-23",
      "created_at": "2026-06-26T15:09:42+00:00",
      "data": {
        "number_id": "7c2a1f0e-9b3d-4a8e-bf21-0c5d6e7f8a90",
        "phone_number": "+15551234567",
        "carrier": "verizon",
        "status": "active",
        "verified_at": "2026-06-26T15:09:42+00:00"
      }
    }
    ```

    Fires when a [monitored number](/api-reference/spam-monitoring) completes verification and daily monitoring begins.
  </Tab>

  <Tab title="webhook.ping">
    ```json theme={null}
    {
      "id": "evt_ping_a1b2c3d4e5f6",
      "type": "webhook.ping",
      "api_version": "2026-04-23",
      "created_at": "2026-04-24T12:00:00+00:00",
      "data": { "challenge": "rXk_pT8mZ2vBqYsF" }
    }
    ```

    Pings carry a random challenge string; you don't need to do anything with it — just respond `2xx`.
  </Tab>
</Tabs>

## Headers we send

Every delivery includes:

| Header                      | Example                    | Meaning                                                                                      |
| --------------------------- | -------------------------- | -------------------------------------------------------------------------------------------- |
| `Content-Type`              | `application/json`         | Always JSON; body is a single object.                                                        |
| `User-Agent`                | `OptimalDial-Webhooks/1.0` | Identifies the sender.                                                                       |
| `X-OptimalDial-Signature`   | `t=1714000000,v1=2c9a…7b8` | HMAC-SHA256 of `{timestamp}.{body}` — see [signature verification](#signature-verification). |
| `X-OptimalDial-Event`       | `upload.completed`         | The event type. Same as `body.type`.                                                         |
| `X-OptimalDial-Event-Id`    | `evt_a1b2c3d4…`            | The event identifier. Same as `body.id`.                                                     |
| `X-OptimalDial-Delivery-Id` | `b8e1c2a3-…`               | Unique per delivery attempt sequence. Multiple attempts to the same endpoint share this ID.  |
| `X-OptimalDial-Attempt`     | `1`                        | 1-indexed attempt counter.                                                                   |
| `X-OptimalDial-Timestamp`   | `1714000000`               | The Unix timestamp embedded in the signature.                                                |

You should log `X-OptimalDial-Delivery-Id` with every received webhook — it's the fastest way for us to debug a delivery problem with you.

## Signature verification

The signature is HMAC-SHA256 of `f"{timestamp}.{body}"` keyed by your endpoint's secret, in Stripe-compatible format. Verifying it has three parts:

1. Parse the `t` and `v1` values out of the `X-OptimalDial-Signature` header.
2. Reject if the timestamp is more than **5 minutes** off from your server clock (replay protection).
3. Recompute the HMAC and compare it to `v1` with a constant-time comparison.

**Always verify against the raw request body bytes**, before any framework parses or re-serialises the JSON. Pretty-printing or re-ordering keys breaks the signature.

<Tabs>
  <Tab title="Node.js / Express">
    ```ts theme={null}
    import crypto from "node:crypto";
    import express from "express";

    const app = express();

    // CRITICAL: keep the raw body around — JSON.parse changes it
    app.use("/optimaldial-webhook", express.raw({ type: "application/json" }));

    const SECRET = process.env.OPTIMALDIAL_WEBHOOK_SECRET!;
    const REPLAY_WINDOW_SECONDS = 300;

    function verify(rawBody: Buffer, header: string, secret: string): boolean {
      const parts = Object.fromEntries(
        header.split(",").map(kv => kv.split("=", 2)),
      );
      const ts = Number(parts.t);
      const provided = parts.v1;
      if (!ts || !provided) return false;
      if (Math.abs(Date.now() / 1000 - ts) > REPLAY_WINDOW_SECONDS) return false;

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

      // Constant-time comparison; throws if lengths differ, so guard first
      if (expected.length !== provided.length) return false;
      return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
    }

    app.post("/optimaldial-webhook", (req, res) => {
      const signature = req.header("X-OptimalDial-Signature") ?? "";
      if (!verify(req.body, signature, SECRET)) {
        return res.status(401).send("invalid signature");
      }

      const event = JSON.parse(req.body.toString("utf8"));
      // …handle event…
      res.status(204).end();
    });
    ```
  </Tab>

  <Tab title="Python / Flask">
    ```python theme={null}
    import hashlib, hmac, os, time
    from flask import Flask, request, abort

    app = Flask(__name__)
    SECRET = os.environ["OPTIMALDIAL_WEBHOOK_SECRET"].encode()
    REPLAY_WINDOW_SECONDS = 300


    def verify(raw_body: bytes, header: str, secret: bytes) -> bool:
        try:
            parts = dict(kv.split("=", 1) for kv in header.split(","))
            ts = int(parts["t"])
            provided = parts["v1"]
        except Exception:
            return False
        if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS:
            return False
        expected = hmac.new(
            secret,
            f"{ts}.{raw_body.decode('utf-8')}".encode(),
            hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(expected, provided)


    @app.post("/optimaldial-webhook")
    def receive():
        raw = request.get_data()  # bytes, BEFORE any json parsing
        sig = request.headers.get("X-OptimalDial-Signature", "")
        if not verify(raw, sig, SECRET):
            abort(401)

        event = request.get_json()
        # …handle event…
        return ("", 204)
    ```
  </Tab>

  <Tab title="Python / FastAPI">
    ```python theme={null}
    import hashlib, hmac, os, time
    from fastapi import FastAPI, Header, HTTPException, Request

    app = FastAPI()
    SECRET = os.environ["OPTIMALDIAL_WEBHOOK_SECRET"].encode()
    REPLAY_WINDOW_SECONDS = 300


    def verify(raw_body: bytes, header: str, secret: bytes) -> bool:
        try:
            parts = dict(kv.split("=", 1) for kv in header.split(","))
            ts = int(parts["t"])
            provided = parts["v1"]
        except Exception:
            return False
        if abs(int(time.time()) - ts) > REPLAY_WINDOW_SECONDS:
            return False
        expected = hmac.new(
            secret,
            f"{ts}.{raw_body.decode('utf-8')}".encode(),
            hashlib.sha256,
        ).hexdigest()
        return hmac.compare_digest(expected, provided)


    @app.post("/optimaldial-webhook")
    async def receive(
        request: Request,
        x_optimaldial_signature: str = Header(default=""),
    ):
        raw = await request.body()
        if not verify(raw, x_optimaldial_signature, SECRET):
            raise HTTPException(status_code=401, detail="invalid signature")

        event = await request.json()
        # …handle event…
        return {}
    ```
  </Tab>
</Tabs>

If you're rolling your own verifier in another language, stick to these rules and you'll match our wire format exactly:

* Sign over `f"{timestamp}.{body}"` — the dot is literal.
* HMAC-SHA256, output as **lowercase hex**.
* Reject when `|now - timestamp| > 300`.
* Use a constant-time comparator (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node, etc.).

## Retry policy

Each delivery has its own retry schedule. We attempt up to **6 times** with the following backoff between attempts:

| Attempt | Time after the previous attempt |
| ------- | ------------------------------- |
| 1       | 0 (initial send)                |
| 2       | 1 minute                        |
| 3       | 5 minutes                       |
| 4       | 30 minutes                      |
| 5       | 2 hours                         |
| 6       | 12 hours                        |

After the 6th attempt fails, the delivery is marked `exhausted` and we stop trying. Total wall-clock window from first attempt to last is approximately 14 hours and 36 minutes.

Per-attempt timeout: **10 seconds**. If your endpoint hasn't responded in that window, we count the attempt as failed and move it onto the next backoff.

### Which responses count as success vs. retryable vs. permanent

| Receiver returned                           | We treat it as                                                                                                                                      |
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `2xx`                                       | Delivered. Future events will continue to fire.                                                                                                     |
| `4xx` (except `408` and `429`)              | **Permanent** failure. We do not retry. The delivery is marked `exhausted` immediately and we bump the consecutive-failure counter on the endpoint. |
| `408`, `429`, `5xx`, network error, timeout | Retryable. We schedule the next backoff.                                                                                                            |
| Followed redirect                           | We do **not** follow redirects. Configure your final URL directly.                                                                                  |

## Auto-disable after sustained failure

We keep a `consecutive_failures` counter on each endpoint. Every failed attempt (whether the delivery eventually succeeded on retry or got permanently rejected) increments it; every successful delivery resets it to `0`.

After **20** consecutive failures we set `is_active: false` and stamp `disabled_at` on the endpoint. No further events go out until you re-enable it via `PATCH /api/v1/webhooks/{id}` with `{"is_active": true}` (which also resets the counter and clears `disabled_at`).

This is a backstop for outages — if your receiver is down for a sustained period, we'd rather pause than carpet-bomb you with retries when you come back. Anything queued during the outage that exhausted its retries is gone; reconcile by listing uploads with `GET /api/v1/uploads` and looking at their statuses.

## URL safety

When you register a webhook endpoint we validate the URL before accepting it:

* **Scheme must be `https://`.** Plain `http://` is rejected unless the host appears in the server's `WEBHOOK_ALLOW_HTTP_HOSTS` allow-list (used for local development on the OptimalDial side; never enabled in production).
* **DNS resolution must return a public IP.** We reject:
  * Private ranges (RFC 1918, RFC 4193 unique-local IPv6).
  * Loopback (`127.0.0.0/8`, `::1`).
  * Link-local (`169.254.0.0/16`, `fe80::/10`).
  * Multicast and reserved/unspecified addresses.
  * Cloud metadata endpoints: `169.254.169.254` (AWS, GCP, Azure), `100.100.100.200` (Alibaba), `fd00:ec2::254` (IPv6 metadata).
* **Redirects are not followed.** Whatever URL you register is the URL we POST to.

These rules apply on every delivery as well as on registration — if your DNS later flips to a private IP, the delivery will fail and the failure counter will tick up.

## Best practices

1. **Idempotency.** Each event carries an `id` (also in `X-OptimalDial-Event-Id`). We may send the same event more than once if a previous attempt was unacknowledged — store the IDs you've already processed and de-dupe on receipt. A 24-hour TTL on the de-dupe set is plenty.
2. **Respond fast, process async.** Return `2xx` as soon as you've persisted the event ID and queued the work. Real processing (downloading large CSVs, mutating your DB) should not happen inside the request handler — you have 10 seconds total before we count the attempt as failed.
3. **Always verify the signature first.** Even before parsing the body. An unverified webhook is just an arbitrary HTTP request to a URL someone scraped from your DNS records.
4. **Log the delivery ID.** `X-OptimalDial-Delivery-Id` is the fastest way for us to find the row in our delivery table when you ask "did you actually try to send me this?".
5. **Don't 5xx on unknown event types.** If we add a new event type, you don't want every delivery to start failing — return `2xx` and ignore unknown types until you're ready to handle them.
6. **Use one secret per endpoint.** Each registered endpoint has its own secret. Don't share them, and don't try to re-use a deleted endpoint's secret on a new one.
7. **Re-fetch download URLs.** The signed URLs in `upload.completed` payloads are short-lived. If you can't process the file immediately, store the `upload.id` and call `GET /api/v1/uploads/{id}/download/processed` later for a fresh URL.
8. **Handle `webhook.ping` like any other event.** It arrives at registration time and carries no business meaning — just respond `2xx` so the registration completes.
