ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Webhooks

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:

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 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 has the exact signed string, a worked example, code without the SDK, and the legacy header.

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
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);
  },
);

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

FeatureLegacy (/v1/webhook)Endpoints (/v1/developer/webhook-endpoints)
Number of URLs1 per orgMany per org
Event coveragetelephony.* / web.* onlyAll 10 event types
Event filter—Per-endpoint
RetriesNone, except one for the phone-call configuration exchange8 attempts over 24 h
Envelopetype + datatype + data + event_id
Secret rotationReplaces single secretPer-endpoint secret
Disable without deletePUT /v1/webhook with {"url": ""}status=disabled
Status visibility—active / disabled / failing
Blocking config exchangeYes (telephony.incoming / web.incoming)Never — notifications only
Best forDynamic call configurationEvent 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.