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

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