Open in
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,408or429response, 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
4xxresponse, a response over the size limit, or a2xxwhose 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 sameX-ThunderPhone-Idempotency-Keyheader (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"
}
}| Field | Type | Description |
|---|---|---|
call_id | integer | Call id — stable across all events for this call |
from_number | string | E.164 caller number |
to_number | string | E.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"
}
}| Field | Type | Description |
|---|---|---|
call_id | integer | Call id |
origin_domain | string | The 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_prefix | string | First characters of the publishable key that opened the session |
language, primary_language | string | Present when the widget session requested a language override |
voice | string | Present when the widget session requested a voice override |
website_context | string | Present 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": []
}| Field | Type | Required | Description |
|---|---|---|---|
prompt | string | yes | System prompt driving the agent |
voice | string | yes | Voice id from GET /v1/voices, e.g. john. voice_name is accepted as an alias. Unknown voices fail validation and reject the call |
product | string | no | Defaults to spark. Allowed: spark, bolt, storm-base, storm-base-with-ack, storm-extra, storm-extra-with-ack |
thinking_level | string | no | minimal, base (default), or extra. Overridden for Storm products: storm-extra* forces extra, other storm-* force base |
audio_context_mode | string | no | full (default) or reduced |
watchdog_enabled | boolean | no | Enable supervision for this call. Default false |
additional_audio_context | boolean | null | no | Include 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_mode | string | no | none, acknowledgement (default), or tick |
language | string | no | Shorthand for primary_language |
primary_language | string | no | Language code, normalized (default en). Unresolvable codes reject the call |
has_additional_languages | boolean | no | Default false |
additional_languages | array of string | no | Extra languages the agent may switch to |
native_voice_switching | boolean | no | Default 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_switching | string | null | no | ask_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_track | string | null | no | Ambient audio id or null |
acknowledgement_prompt_mode | string | no | auto (default) or manual (Storm-with-ack products) |
acknowledgement_prompt | string | no | Used when acknowledgement_prompt_mode="manual" |
silence_interval_seconds | integer | null | no | 5–120. Seconds of caller silence before a check-in |
silence_max_checkins | integer | null | no | 1–10 |
silence_checkins_enabled | boolean | no | Default true |
connect_tone_enabled | boolean | no | Default false |
pronunciation_dicts | boolean | null | no | Default 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_action | string | no | prompt (default), hangup, or message |
voicemail_message | string | no | Used when voicemail_action="message" |
agent_name | string | no | Display name reported to dashboards and the widget |
org_name | string | no | Organization display name for the agent's persona |
tools | array | no | Inline function-tool schemas (see Function Tools) |
transfer_destinations | array of object | null | no | Up 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 |
timezone | string | no | IANA 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_id | integer | no | Optional 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
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 {}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
| Product | Latency | Reasoning | Acknowledgement |
|---|---|---|---|
spark | Lowest | Basic | — |
bolt | Low | Improved | — |
storm-base | Medium | Strong | — |
storm-base-with-ack | Medium | Strong | Auto filler while thinking |
storm-extra | Higher | Deep | — |
storm-extra-with-ack | Higher | Deep | Auto filler while thinking |
Related
The non-blocking end-of-call event.
Full JSON schema for tools[] and the signed endpoint contract.
Subscribe multiple URLs to telephony.incoming / web.incoming.
Patterns for per-caller prompts, tools, and A/B tests.