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

Developer cookbook

Dynamic per-call configuration

Pick the answering agent — or rewrite its prompt and settings — separately for every incoming call, driven by custom logic in a webhook you control.

By default every phone number and publishable key has a static agent assigned. When you need per-caller or per-visitor customization — VIP routing, logged-in user context, A/B prompt tests — switch to webhook-mode and let your server decide.

How it works

  1. You subscribe to the telephony.incoming (phone) or web.incoming (widget) event. Both are blocking webhooks: ThunderPhone waits for your response before continuing the call, up to 13 seconds for a phone call (one retry included) and 10 seconds for a widget session.
  2. ThunderPhone sends you {call_id, from_number, to_number} (widget sessions carry widget-specific fields instead of numbers — see the request schema).
  3. Your server responds with an agent configuration (prompt, voice, product, tools). ThunderPhone uses that configuration for the call.
  4. There is no fallback agent. If you return {} or an invalid configuration, or the request still fails after its retry, the phone call is ended right away and recorded with end_reason inbound_config_webhook_failed; a widget session request fails.

1. Configure the webhook destination

For phone numbers, subscribe your endpoint to telephony.incoming:

curl -X POST https://api.thunderphone.com/v1/developer/webhook-endpoints \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "label":  "Prod call-incoming",
    "url":    "https://example.com/thunderphone/incoming",
    "events": ["telephony.incoming"]
  }'

The response includes a one-shot secret — save it; you'll use it for signature verification.

For widget sessions, create a publishable key in mode="webhook" with your endpoint URL baked in:

curl -X POST https://api.thunderphone.com/v1/publishable-key \
  -H "Authorization: Bearer sk_live_YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name":            "Dynamic widget",
    "mode":            "webhook",
    "webhook_url":     "https://example.com/thunderphone/widget-incoming",
    "allowed_domains": ["example.com"]
  }'

The widget will POST to this URL on every session start.

2. Implement the handler

Three rules of thumb:

  • Verify the signature on every request (see Verify webhook signatures). Don't skip this in dev — get it right once and reuse.
  • Respond fast. A phone call's first attempt is cut off at 10 seconds and the whole exchange at 13, and every second is dead air for the caller. Do database lookups if you need to, but don't call downstream LLMs synchronously — if you want dynamic prompt generation, pre-compute and cache.
  • Fall back cleanly. For any unexpected state, answer with a default prompt and voice (or a saved agent_id). Returning {} or an error status ends the call.
FastAPI
import hashlib
import hmac
import json
import os
 
from fastapi import FastAPI, HTTPException, Request
 
app = FastAPI()
SECRET = os.environ["THUNDERPHONE_WEBHOOK_SECRET"]
 
def verify(body: bytes, sig: str) -> bool:
    expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig or "")
 
@app.post("/thunderphone/incoming")
async def incoming(request: Request):
    body = await request.body()
    if not verify(body, request.headers.get("X-ThunderPhone-Signature", "")):
        raise HTTPException(401)
 
    event = json.loads(body)
    if event["type"] not in ("telephony.incoming", "web.incoming"):
        return {}  # fall back to default
 
    caller = event["data"]["from_number"]
    # Cheap DB lookup: is this a known VIP?
    customer = lookup_customer(caller)
    if customer and customer.tier == "vip":
        return {
            "prompt":  f"You are a VIP concierge for {customer.name}. Be proactive…",
            "voice":   "john",
            "product": "storm-base",
        }
    return {}  # default agent handles non-VIPs
 
def lookup_customer(phone: str):
    # ... your CRM integration ...
    pass
Express
import crypto from "node:crypto";
import express from "express";
 
const app = express();
const SECRET = process.env.THUNDERPHONE_WEBHOOK_SECRET;
 
function verify(body, sig) {
  const expected = crypto.createHmac("sha256", SECRET).update(body).digest("hex");
  return sig &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}
 
app.post(
  "/thunderphone/incoming",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    if (!verify(req.body, req.header("X-ThunderPhone-Signature"))) {
      return res.sendStatus(401);
    }
    const event = JSON.parse(req.body.toString("utf8"));
 
    const IMPORTANT_TYPES = new Set([
      "telephony.incoming",
      "web.incoming",
    ]);
    if (!IMPORTANT_TYPES.has(event.type)) return res.json({});
 
    const customer = await lookupCustomer(event.data.from_number);
    if (customer?.tier === "vip") {
      return res.json({
        prompt:  `You are a VIP concierge for ${customer.name}. Be proactive…`,
        voice:   "john",
        product: "storm-base",
      });
    }
    res.json({}); // fall back to default agent
  },
);

3. Response schema

The response body matches the incoming-call response schema exactly. The commonly-used fields:

FieldTypeDescription
promptstring (required)System prompt for the agent
voicestring (required)Voice id from GET /v1/voices
productstringDefaults to spark. Experimental products are only available for allowlisted organizations.
background_trackstring | nullAmbient audio id
acknowledgement_prompt_modestringauto or manual (Storm-with-ack only)
acknowledgement_promptstringRequired when mode is manual
toolsarrayInline function-tool schemas — see Function Tools

Keep a saved agent and supply variables

Return {"agent_id": 12, "variables": {"name": "Ada"}} to use that organization's saved agent with per-call data. Its prompt can contain {{name}} or {{name|Friend}}. Webhook variables merge over request-level variables; null uses the placeholder default, or empty text if none is provided. The final values and unresolved names appear in call details and completion webhooks. Saved-agent responses accept only agent_id, variables and timezone. If prompt is present, the response uses inline configuration and ignores agent_id (including null or non-integer metadata); the inline prompt must still be valid. Inline configuration responses may also include variables. Saved-agent responses use the agent's deployed A/B split on both phone and widget calls, then render variables. See call variables for limits and session API support. Blocking configuration comes from the legacy phone-number/organization URL or a webhook-mode widget key; endpoint-system incoming events are notifications only.

Set the time zone per call

One agent can answer for contacts in different time zones. Add timezone, an IANA name, to a saved-agent or inline response and the agent states the date and time and offers times in that zone for this call, ahead of the agent's own time zone:

{"agent_id": 12, "timezone": "America/Chicago", "variables": {"name": "Ada"}}

Send the zone rather than a timestamp in variables: the agent's clock keeps running during the call, and a time you pass in goes stale. A value that is not an IANA zone is ignored and the call connects in the agent's zone.

Patterns

Logged-in user context

In webhook-mode widgets, the visitor's page already knows who they are. Call your webhook with a query string parameter the widget SDK forwards (?customer_id=123) and look up the customer server-side.

A/B prompt rollout

Before you hand-roll this, note that ThunderPhone has a native Experiments feature (/dashboard/experiments and the agent builder's A/B tab) that defines variants, splits traffic, and compares outcomes per variant — no webhook required.

If you need webhook-side control anyway: hash call_id → bucket; serve prompt A for 0..49 and prompt B for 50..99. Record which bucket you chose in your own DB and later correlate against the completed call's grade.

Time-based routing

Business hours → "live support" agent; after-hours → "take a message" agent. Pure switch on new Date().getUTCHours() in your handler.


Next steps