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

Webhooks

telephony.incoming / web.incoming

Blocking webhook that shapes an inbound call

When an inbound phone call reaches a number without an assigned agent, or a web widget session starts on a publishable key in mode="webhook", ThunderPhone sends a blocking telephony.incoming / web.incoming request to your legacy webhook URL and waits for a configuration response: up to 13 seconds for a phone call, one retry included, and up to 10 seconds for a widget session (see Timeouts and retries). Use this exchange to dynamically choose a prompt, voice, and tools per call — see the dynamic call configuration guide for the end-to-end pattern.

Timeouts and retries

Answer fast: the caller is waiting on the line while you decide.

For a phone call (telephony.incoming):

  • The first attempt may take up to 10 seconds.
  • If it times out, cannot connect, or gets a 5xx, 408 or 429 response, ThunderPhone tries once more. The retry gets whatever is left of a 13-second budget for the whole exchange: about 3 seconds after a first attempt that timed out, nearly all 13 after a fast failure. There is no retry when less than 2 seconds would be left.
  • Any other 4xx response, a response over the size limit, or a 2xx whose body is not a valid configuration is final and is not retried.
  • Both attempts send the same body, the same X-ThunderPhone-Signature, and the same X-ThunderPhone-Idempotency-Key header (telephony-incoming-<call_id>), so a handler that has side effects can recognize the retry.

There is no fallback agent. If no usable configuration arrives, the call is ended right away instead of leaving the caller on a silent line. It appears in call history as a failed, unbilled call with end_reason inbound_config_webhook_failed, and the usual telephony.complete event is sent.

A widget session (web.incoming) gets a single attempt of up to 10 seconds. If it fails, the session request fails with 502 or 422.

Request payload

For phone calls (telephony.incoming):

{
  "type": "telephony.incoming",
  "data": {
    "call_id":     987654321,
    "from_number": "+14155550199",
    "to_number":   "+15551234567"
  }
}
FieldTypeDescription
call_idintegerCall id — stable across all events for this call
from_numberstringE.164 caller number
to_numberstringE.164 destination (one of your ThunderPhone numbers)

For web widget sessions (web.incoming) the data identifies the embedding page instead of phone numbers:

{
  "type": "web.incoming",
  "data": {
    "call_id": 987654322,
    "origin_domain": "https://example.com",
    "publishable_key_prefix": "pk_live_a1b2"
  }
}
FieldTypeDescription
call_idintegerCall id
origin_domainstringThe page origin hosting the widget, as the browser sent it. A non-browser client can send any value, so do not treat it as proof of where the request came from
publishable_key_prefixstringFirst characters of the publishable key that opened the session
language, primary_languagestringPresent when the widget session requested a language override
voicestringPresent when the widget session requested a voice override
website_contextstringPresent when the widget passed per-session page context

The override fields (language, voice, website_context, and any browser variables) arrive only when the publishable key allows them in its browser overrides. A session request carrying one the key does not allow is rejected before this webhook is called.


Response schema

Return a JSON object describing the agent configuration for this call. prompt and voice are required; everything else is optional.

{
  "prompt":  "You are a helpful booking assistant for Acme Restaurant.",
  "voice":   "john",
  "product": "spark",
  "background_track": null,
  "tools":   []
}
FieldTypeRequiredDescription
promptstringyesSystem prompt driving the agent
voicestringyesVoice id from GET /v1/voices, e.g. john. voice_name is accepted as an alias. Unknown voices fail validation and reject the call
productstringnoDefaults to spark. Allowed: spark, bolt, storm-base, storm-base-with-ack, storm-extra, storm-extra-with-ack
thinking_levelstringnominimal, base (default), or extra. Overridden for Storm products: storm-extra* forces extra, other storm-* force base
audio_context_modestringnofull (default) or reduced
watchdog_enabledbooleannoEnable supervision for this call. Default false
additional_audio_contextboolean | nullnoInclude the last few turns of caller audio rather than only the most recent turn, improving corrections and spelling/number-heavy data collection at a small latency/cost overhead. Defaults on for inbound sessions and off for outbound phone calls; null keeps the default
storm_feedback_modestringnonone, acknowledgement (default), or tick
languagestringnoShorthand for primary_language
primary_languagestringnoLanguage code, normalized (default en). Unresolvable codes reject the call
has_additional_languagesbooleannoDefault false
additional_languagesarray of stringnoExtra languages the agent may switch to
native_voice_switchingbooleannoDefault false. When the call switches to another language, swap to a voice native to that language (matched by gender) instead of keeping the configured voice
language_switchingstring | nullnoask_first (default) or follow_caller; null is the same as ask_first. How a multilingual agent handles the other person speaking another of its languages. ask_first switches at once when they ask for the language or say a full sentence in it, and asks whether to continue in it after something short, like a "sí". follow_caller switches to whatever configured language they speak, even a single word. Applies only when additional_languages is set
background_trackstring | nullnoAmbient audio id or null
acknowledgement_prompt_modestringnoauto (default) or manual (Storm-with-ack products)
acknowledgement_promptstringnoUsed when acknowledgement_prompt_mode="manual"
silence_interval_secondsinteger | nullno5–120. Seconds of caller silence before a check-in
silence_max_checkinsinteger | nullno1–10
silence_checkins_enabledbooleannoDefault true
connect_tone_enabledbooleannoDefault false
pronunciation_dictsboolean | nullnoDefault false; null is the same as false. When true, the call's Cartesia speech uses ThunderPhone's pronunciation dictionary for the call's language, where one exists. Works for phone and widget calls. Applies only to ThunderPhone's own Cartesia voices, not to voices on your own Cartesia API key. Not used with agent_id, where the saved agent's settings apply
voicemail_actionstringnoprompt (default), hangup, or message
voicemail_messagestringnoUsed when voicemail_action="message"
agent_namestringnoDisplay name reported to dashboards and the widget
org_namestringnoOrganization display name for the agent's persona
toolsarraynoInline function-tool schemas (see Function Tools)
transfer_destinationsarray of object | nullnoUp to 50 {name, number} entries this call may transfer to, validated like an agent's transfer numbers. The agent transfers by name and only to these; [] means no transfers. Omit it or send null to let the agent transfer to a number it chooses
timezonestringnoIANA time zone for this call, such as America/Chicago. The agent states the date and time in it, ahead of the agent's own time zone. Also allowed next to agent_id. Values that are not IANA zones are ignored. See Set the time zone per call
call_idintegernoOptional echo of the request's call id; ignored

Because prompt and voice are required, returning {} or any response that fails validation rejects the call: a phone call is ended as described in Timeouts and retries, and a widget session request fails with 422. There is no static-agent fallback on this path (a number or key in webhook mode has no assigned agent).


Response size limit


Example handler

Python (FastAPI)
import hashlib
import hmac
import json
import os
 
from fastapi import FastAPI, HTTPException, Request
 
app = FastAPI()
WEBHOOK_SECRET = os.environ["THUNDERPHONE_WEBHOOK_SECRET"]
 
def verify(body: bytes, signature: str) -> bool:
    expected = hmac.new(WEBHOOK_SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature or "")
 
@app.post("/thunderphone-webhook")
async def webhook(request: Request):
    body = await request.body()
    if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
        raise HTTPException(status_code=401)
 
    event = json.loads(body)
    if event["type"] == "telephony.incoming":
        caller = event["data"]["from_number"]
        prompt = (
            "Greet the caller as a San Francisco local…"
            if caller.startswith("+1415")
            else "You are a friendly customer support agent…"
        )
        return {
            "prompt": prompt,
            "voice": "john",
            "product": "spark",
        }
    if event["type"] == "web.incoming":
        return {
            "prompt": "You are the website's helpful voice assistant…",
            "voice": "john",
            "product": "spark",
        }
    return {}
Node.js (Express)
import crypto from "node:crypto";
import express from "express";
 
const app = express();
const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;
 
function verify(body, signature) {
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(body)
    .digest("hex");
  return signature &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
 
app.post(
  "/thunderphone-webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    if (!verify(req.body, req.header("X-ThunderPhone-Signature"))) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString("utf8"));
 
    if (event.type === "telephony.incoming" || event.type === "web.incoming") {
      const caller = event.data.from_number || "web";
      const prompt = caller.startsWith("+1415")
        ? "Greet the caller as a San Francisco local…"
        : "You are a friendly customer support agent…";
      return res.json({
        prompt,
        voice: "john",
        product: "spark",
      });
    }
    res.json({});
  },
);

Response with function tools

Attach tools so the AI can call your APIs mid-conversation:

{
  "prompt":  "You are a booking assistant. Use the available tools to help customers schedule appointments.",
  "voice":   "john",
  "product": "spark",
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "search_appointments",
        "description": "Find available appointment slots",
        "parameters": {
          "type": "object",
          "properties": {
            "date": { "type": "string", "description": "YYYY-MM-DD" },
            "service": { "type": "string" }
          },
          "required": ["date"]
        }
      },
      "endpoint": {
        "url": "https://api.example.com/appointments/search",
        "method": "POST",
        "headers": {
          "X-Api-Key": "your-key"
        }
      }
    }
  ]
}

Product tier cheat sheet

ProductLatencyReasoningAcknowledgement
sparkLowestBasic—
boltLowImproved—
storm-baseMediumStrong—
storm-base-with-ackMediumStrongAuto filler while thinking
storm-extraHigherDeep—
storm-extra-with-ackHigherDeepAuto filler while thinking