Open in
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:
Multiple URLs, per-endpoint secrets, per-endpoint event filters,
and automatic retries.
Manage via GET/POST/PATCH/DELETE /v1/developer/webhook-endpoints.
One URL per org. Carries the call-lifecycle events, including the
blocking configuration exchanges. Managed at GET/PUT /v1/webhook.
All listed event types in the events catalog 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 and webhook-mode
tool 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:
{
"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:
{
"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 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 whosetis 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-Signaturesigns the raw body only. It is still sent, unchanged, so existing verifiers keep working.
Steps
- Read the header, the method, the raw path and query, and the raw body before any parsing.
- Compute the hex HMAC-SHA256 of
<t>\n<METHOD>\n<path and query>\n<body>with your secret. - Reject the request if
tis more than 300 seconds off, or if the result does not matchv1in 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 has the exact signed string, a worked example, code without the SDK, and the legacy header.
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 "", 204import 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);
},
);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
configuration exchange, which is retried once.
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; 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.
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.
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.
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
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 allows 20 s
by default, and tool declarations may set a top-level timeout.
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.
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) | 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
All event types and their payloads.
Manage multiple endpoints, event filters, and secrets.
The blocking request your server must answer to configure calls.
Post-call payload with transcript, recording, and metrics.