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

# Webhooks Overview

> How ThunderPhone delivers real-time events, how to verify signatures, and how the legacy and endpoint-based delivery models compare.

ThunderPhone sends HTTP `POST` requests to your server when things
happen during a call — an inbound call starts, a call ends, a grading
run completes, an alert fires, and so on. There are **two delivery
models**:

<CardGroup cols={2}>
  <Card title="Webhook endpoints (recommended)" icon="bolt" href="/webhooks/endpoints">
    Multiple URLs, per-endpoint secrets, per-endpoint event filters,
    and automatic retries.
    Manage via `GET/POST/PATCH/DELETE /v1/developer/webhook-endpoints`.
  </Card>

  <Card title="Single-URL legacy webhook" icon="link" href="/api-reference/organizations#legacy-single-url-webhook">
    One URL per org. Carries the call-lifecycle events, including the
    **blocking** configuration exchanges. Managed at `GET/PUT /v1/webhook`.
  </Card>
</CardGroup>

All ten event types in the [events catalog](/webhooks/events) are
delivered through webhook endpoints. The six call-lifecycle events
(`telephony.incoming`, `telephony.complete`, `telephony.tool`,
`web.incoming`, `web.complete`, `web.tool`) are **also** sent to the
legacy single-URL webhook — if you have both a legacy URL and a
matching endpoint, you receive the event on **both** paths. Blocking
behavior (the [`telephony.incoming` / `web.incoming` configuration
exchange](/webhooks/call-incoming) and webhook-mode
[tool dispatch](/tools/overview#webhook-mode-dispatch)) lives
exclusively on the legacy path; every endpoint delivery is a
fire-and-forget notification.

## Payload format

Endpoint deliveries are a JSON object with `data`, `event_id`, and
`type`:

```json theme={null}
{
  "data": {
    "call_id": 987654321,
    "from_number": "+14155550199",
    "to_number": "+15551234567"
  },
  "event_id": "3f6b2ad0-1c9e-4a57-9f2b-8f6f0f9d2f11",
  "type": "telephony.incoming"
}
```

`event_id` is unique per emitted event. It is identical across retries
**and** across every endpoint that receives the event — dedup on it.

The legacy single-URL webhook sends the same `type` and `data` but
**without** `event_id`:

```json theme={null}
{
  "type": "telephony.incoming",
  "data": { "call_id": 987654321, "from_number": "+14155550199", "to_number": "+15551234567" }
}
```

On the wire, every body is serialized canonically — keys sorted
alphabetically, no whitespace, UTF-8. The pretty-printed examples in
these docs are for readability only.

See the [Events catalog](/webhooks/events) for the full list of event
types and payload fields.

## Signature verification

Every request carries an HMAC-SHA256 signature over the **raw request
body** in the `X-ThunderPhone-Signature` header. The signing key is the
endpoint's `secret` (or your org-level webhook `secret` for legacy
deliveries).

### Steps

1. Read the raw request body **before** any parsing.
2. Compute `hmac_sha256(secret, body).hexdigest()`.
3. Compare in constant time to the `X-ThunderPhone-Signature` header.

We sign exactly the bytes we transmit, and those bytes are the
canonical JSON serialization (sorted keys, compact separators). So
verifying against the raw body always works — and if your framework
only hands you parsed JSON, re-serializing it with sorted keys and
compact separators produces the identical bytes. Both recipes are
covered in the [verification guide](/guides/verify-webhook-signatures).

<CodeGroup>
  ```python Python theme={null}
  import hmac
  import hashlib

  def verify_signature(body: bytes, signature: str, secret: str) -> bool:
      expected = hmac.new(
          secret.encode("utf-8"),
          body,
          hashlib.sha256,
      ).hexdigest()
      return hmac.compare_digest(expected, signature or "")

  # Example Flask handler
  from flask import Flask, request, abort
  app = Flask(__name__)

  @app.post("/thunderphone-webhook")
  def handle():
      body = request.get_data()
      sig = request.headers.get("X-ThunderPhone-Signature", "")
      if not verify_signature(body, sig, WEBHOOK_SECRET):
          abort(401)
      event = request.get_json()
      # dispatch on event["type"] …
      return "", 204
  ```

  ```javascript Node.js (Express) theme={null}
  import crypto from "node:crypto";
  import express from "express";

  function verifySignature(body, signature, secret) {
    const expected = crypto
      .createHmac("sha256", secret)
      .update(body)
      .digest("hex");
    if (!signature || expected.length !== signature.length) return false;
    return crypto.timingSafeEqual(
      Buffer.from(expected),
      Buffer.from(signature),
    );
  }

  const app = express();
  app.post(
    "/thunderphone-webhook",
    express.raw({ type: "application/json" }),
    (req, res) => {
      const sig = req.header("X-ThunderPhone-Signature") || "";
      if (!verifySignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
        return res.sendStatus(401);
      }
      const event = JSON.parse(req.body.toString("utf8"));
      // dispatch on event.type …
      res.sendStatus(204);
    },
  );
  ```
</CodeGroup>

## Delivery semantics

These semantics apply to **endpoint** deliveries. The legacy single-URL
webhook is a single synchronous attempt with no retries.

<AccordionGroup>
  <Accordion title="Retries">
    Each event is attempted once immediately. Any `2xx` response
    acknowledges the delivery. On any other outcome (non-2xx,
    connection error, timeout) we retry at **1 m, 5 m, 30 m, 2 h, 6 h,
    12 h, and 24 h after the first attempt** — 8 attempts spanning
    24 hours. If every attempt fails, delivery stops and the endpoint
    is marked `status="failing"` in
    [webhook endpoints](/webhooks/endpoints). Return `2xx` as soon as
    the payload is durably accepted; process asynchronously.
  </Accordion>

  <Accordion title="Ordering">
    Delivery ordering is best-effort. In practice we deliver in the
    order events are emitted, but retries can reorder on failure.
    Always dedup and reconcile by `call_id` / object id.
  </Accordion>

  <Accordion title="Duplicates">
    Delivery is **at-least-once**: a retry after a response we never
    saw can duplicate an event. Every retry carries the same
    `event_id`, so store processed ids and skip repeats. `event_id` is
    also shared across endpoints — two endpoints subscribed to the
    same event receive the same `event_id`.
  </Accordion>

  <Accordion title="Timeouts">
    Endpoint deliveries have a **30 s** timeout per attempt. On the
    legacy path, blocking requests that drive live call behavior — the
    [`telephony.incoming` / `web.incoming`](/webhooks/call-incoming)
    configuration exchange — time out after **10 s**, but a slow
    response delays call pickup, so aim to answer within a couple of
    seconds. Webhook-mode [tool dispatch](/tools/overview) allows 20 s.
  </Accordion>

  <Accordion title="Source IPs">
    Outbound webhooks originate from ThunderPhone's cloud IP range.
    If your firewall requires an allowlist, contact support and we'll
    share the current ranges.
  </Accordion>
</AccordionGroup>

## Choosing between legacy and endpoint-based webhooks

| Feature                  | Legacy (`/v1/webhook`)                                                 | Endpoints (`/v1/developer/webhook-endpoints`) |
| ------------------------ | ---------------------------------------------------------------------- | --------------------------------------------- |
| Number of URLs           | 1 per org                                                              | Many per org                                  |
| Event coverage           | `telephony.*` / `web.*` only                                           | All 10 event types                            |
| Event filter             | —                                                                      | Per-endpoint                                  |
| Retries                  | None                                                                   | 8 attempts over 24 h                          |
| Envelope                 | `type` + `data`                                                        | `type` + `data` + `event_id`                  |
| Secret rotation          | Replaces single secret                                                 | Per-endpoint secret                           |
| Disable without delete   | —                                                                      | `status=disabled`                             |
| Status visibility        | —                                                                      | `active` / `disabled` / `failing`             |
| Blocking config exchange | Yes ([`telephony.incoming` / `web.incoming`](/webhooks/call-incoming)) | Never — notifications only                    |
| Best for                 | Dynamic call configuration                                             | Event consumption in production               |

New integrations should consume events through endpoint-based
webhooks. Keep (or add) a legacy URL only if you configure calls
dynamically at pickup time or use webhook-mode tool dispatch — those
request/response exchanges only run on the legacy path.

***

## Related

<CardGroup cols={2}>
  <Card title="Events catalog" icon="list" href="/webhooks/events">
    All event types and their payloads.
  </Card>

  <Card title="Webhook endpoints" icon="bolt" href="/webhooks/endpoints">
    Manage multiple endpoints, event filters, and secrets.
  </Card>

  <Card title="telephony.incoming / web.incoming" icon="phone" href="/webhooks/call-incoming">
    The blocking request your server must answer to configure calls.
  </Card>

  <Card title="telephony.complete / web.complete" icon="phone" href="/webhooks/call-complete">
    Post-call payload with transcript, recording, and metrics.
  </Card>
</CardGroup>
