---
title: "Webhooks Overview"
description: "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 listed 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
{
  "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
{
  "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 two signatures, both HMAC-SHA256 keyed with the
endpoint's `secret` (or your org-level webhook `secret` for legacy
deliveries):

- `X-ThunderPhone-Signature-V2: t=<unix seconds>,v1=<hex>` signs the
  timestamp, the method, the path and query, and the raw body. Verify
  this one, and reject requests whose `t` is more than **5 minutes** from
  your clock so a captured request cannot be replayed later. Retries are
  signed again when they are sent.
- `X-ThunderPhone-Signature` signs the raw body only. It is still sent,
  unchanged, so existing verifiers keep working.

### Steps

1. Read the header, the method, the raw path and query, and the raw
   body **before** any parsing.
2. Compute the hex HMAC-SHA256 of
   `<t>\n<METHOD>\n<path and query>\n<body>` with your secret.
3. Reject the request if `t` is more than 300 seconds off, or if the
   result does not match `v1` in a constant-time comparison.

Bodies are the canonical JSON serialization (sorted keys, compact
separators), and we sign exactly the bytes we transmit. The
[verification guide](/guides/verify-webhook-signatures) has the exact
signed string, a worked example, code without the SDK, and the legacy
header.

<CodeGroup>
```python Python (Flask)
from flask import Flask, abort, request
from thunderphone import verify_signature

app = Flask(__name__)

@app.post("/thunderphone-webhook")
def handle():
    query = request.query_string.decode()
    if not verify_signature(
        request.headers.get("X-ThunderPhone-Signature-V2"),
        secret=WEBHOOK_SECRET,
        method=request.method,
        path=request.path + (f"?{query}" if query else ""),
        body=request.get_data(),  # raw bytes
    ):
        abort(401)
    event = request.get_json()
    # dispatch on event["type"] …
    return "", 204
```

```javascript Node.js (Express)
import express from "express";
import { verifySignature } from "@thunderphone/sdk";

const app = express();
app.post(
  "/thunderphone-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const ok = verifySignature({
      header: req.header("X-ThunderPhone-Signature-V2"),
      secret: process.env.WEBHOOK_SECRET,
      method: req.method,
      path: req.originalUrl,
      body: req.body,
    });
    if (!ok) 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, except the
phone-call [`telephony.incoming`](/webhooks/call-incoming#timeouts-and-retries)
configuration exchange, which is retried once.

<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); we email your organization's
    owners and admins and show it in the dashboard. Return `2xx` as soon as
    the payload is durably accepted; process asynchronously.
    Test sends from the endpoint test API get one retry about a minute after
    the first attempt and never change the endpoint's status.
  </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 — are limited to **10 s** for a phone call's
    first attempt and **13 s** for the whole exchange including its one
    retry, and to **10 s** for a widget session. A slow response delays
    call pickup, so aim to answer within a couple of seconds. Webhook-mode [tool dispatch](/tools/overview) allows 20 s
    by default, and tool declarations may set a top-level `timeout`.
  </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, except one for the phone-call configuration exchange | 8 attempts over 24 h |
| Envelope | `type` + `data` | `type` + `data` + `event_id` |
| Secret rotation | Replaces single secret | Per-endpoint secret |
| Disable without delete | `PUT /v1/webhook` with `{"url": ""}` | `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>
