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 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

  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.

Python
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
Node.js (Express)
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);
  },
);

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

FeatureLegacy (/v1/webhook)Endpoints (/v1/developer/webhook-endpoints)
Number of URLs1 per orgMany per org
Event coveragetelephony.* / web.* onlyAll 10 event types
Event filterPer-endpoint
RetriesNone8 attempts over 24 h
Envelopetype + datatype + data + event_id
Secret rotationReplaces single secretPer-endpoint secret
Disable without deletestatus=disabled
Status visibilityactive / 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.