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 up to 10 seconds for a configuration response. 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.
The blocking exchange has no fallback: if your handler returns a
non-2xx status, times out, or returns a config that fails validation,
the call is rejected (the phone call does not connect; the widget
session request fails with 502/422). Answer fast — the caller is
hearing ringback while you decide.
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 |
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 |
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 |
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 |
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) |
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 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.