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 ten 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 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
- Read the raw request body before any parsing.
- Compute
hmac_sha256(secret, body).hexdigest(). - Compare in constant time to the
X-ThunderPhone-Signatureheader.
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.
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 "", 204import 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);
},
);Delivery semantics
These semantics apply to endpoint deliveries. The legacy single-URL webhook is a single synchronous attempt with no retries.
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. Return 2xx as soon as
the payload is durably accepted; process asynchronously.
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 — 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 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 | 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) | 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.