---
title: "Dynamic per-call configuration"
description: "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`](/webhooks/events#telephony-incoming)
   (phone) or [`web.incoming`](/webhooks/events#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](/webhooks/call-incoming)).
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](/webhooks/call-incoming#timeouts-and-retries), the phone
   call is ended right away and recorded with `end_reason`
   `inbound_config_webhook_failed`; a widget session request fails.

<Note>
  Works identically for phone calls (`telephony.incoming`) and widget
  sessions (`web.incoming`), whether delivered to a webhook endpoint
  or to the legacy single-URL webhook.
</Note>

<Warning>
  **No ThunderPhone consent announcement plays on inline webhook-configured
  calls.** This path bypasses the agent-level call-start announcement
  and is expressly excluded from ThunderPhone's consent-announcement
  framework (Terms of Service, "Recording and consent" section). Your
  organization owns all recording, monitoring, AI-participation, and
  caller-identification notices and consents on these calls. Provide
  them in your own flow — for example in the prompt's opening script —
  before enabling inline webhook mode. A webhook response that references a
  saved `agent_id` uses that agent's normal recording and disclosure policy.
</Warning>

## 1. Configure the webhook destination

<Tabs>
<Tab title="Phone calls">
For phone numbers, subscribe your endpoint to `telephony.incoming`:

```bash
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.
</Tab>
<Tab title="Web widget">
For widget sessions, create a publishable key in `mode="webhook"`
with your endpoint URL baked in:

```bash
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.
</Tab>
</Tabs>

## 2. Implement the handler

Three rules of thumb:

- **Verify the signature** on every request (see
  [Verify webhook signatures](/guides/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.

<CodeGroup>
```python 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
```

```javascript 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
  },
);
```
</CodeGroup>

## 3. Response schema

The response body matches the
[incoming-call response schema](/webhooks/call-incoming#response-schema)
exactly. The commonly-used fields:

| Field | Type | Description |
|-------|------|-------------|
| `prompt` | string (required) | System prompt for the agent |
| `voice` | string (required) | Voice id from [`GET /v1/voices`](/api-reference/agents#voices) |
| `product` | string | Defaults to `spark`. Experimental products are only available for allowlisted organizations. |
| `background_track` | string \| null | Ambient audio id |
| `acknowledgement_prompt_mode` | string | `auto` or `manual` (Storm-with-ack only) |
| `acknowledgement_prompt` | string | Required when mode is `manual` |
| `tools` | array | Inline function-tool schemas — see [Function Tools](/tools/overview) |

<Note>
  Per-call speak-order and `max_hold_seconds` aren't available on the
  webhook response. Set them on the
  [Agent](/api-reference/agents) you reference.
</Note>

### 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](/guides/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:

```json
{"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](/guides/concepts#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

<CardGroup cols={2}>
  <Card title="Incoming-call webhook reference" icon="phone" href="/webhooks/call-incoming">
    Exact request + response schemas, including every configuration key.
  </Card>
  <Card title="Verify webhook signatures" icon="shield-check" href="/guides/verify-webhook-signatures">
    Get the HMAC right once; reuse everywhere.
  </Card>
  <Card title="Build a tool integration" icon="screwdriver-wrench" href="/guides/build-tool-integration">
    Combine dynamic routing with per-agent tools.
  </Card>
  <Card title="Delivery semantics" icon="bolt" href="/webhooks/overview">
    Retries, ordering, timeouts.
  </Card>
</CardGroup>
