Skip to main content
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, and so on. There are two delivery models:

Webhook endpoints (recommended)

Multiple URLs, per-endpoint secrets, and per-endpoint event filters. Manage via GET/POST/PATCH/DELETE /v1/developer/webhook-endpoints.

Single-URL legacy webhook

One URL per org receives every event type. Still supported; managed at GET/PUT /v1/webhook.
A request is delivered exactly once for each subscribed endpoint. If you have both a legacy URL and a matching webhook endpoint, the event is sent to both.

Payload format

Every webhook body is a JSON object with type and data:
{
  "type": "call.incoming",
  "data": {
    "call_id": 987654321,
    "from_number": "+14155550199",
    "to_number": "+15551234567"
  }
}
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 JSON 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.
The body is already serialized with sorted keys and no extra whitespace — do not re-serialize before computing the HMAC.
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

Delivery semantics

ThunderPhone retries on any non-2xx response with exponential backoff for up to 24 hours. After 24 hours of failures, the endpoint is marked status="failing" in webhook endpoints. Return 2xx as soon as the payload is durably accepted; process asynchronously.
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.
Webhooks are at-least-once. A duplicate call.complete is rare but possible — be idempotent on call_id.
Non-blocking events (e.g. call.complete) have a 30 s timeout. Blocking events that drive live call behavior (e.g. call.incoming) are expected to respond within 2 s — longer responses delay the call.
Outbound webhooks originate from ThunderPhone’s Cloud Run 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 filterAll eventsPer-endpoint
Secret rotationReplaces single secretPer-endpoint secret
Disable without deletestatus=disabled
Status visibilityactive / disabled / failing
Best forQuick prototypesProduction
New integrations should prefer endpoint-based webhooks. The legacy endpoint is kept fully functional for backward compatibility.

Events catalog

All event types and their payloads.

Webhook endpoints

Manage multiple endpoints, event filters, and secrets.

call.incoming

The blocking request your server must answer to route inbound calls.

call.complete

Post-call payload with transcript, recording, and metrics.