---
title: "telephony.incoming / web.incoming"
description: "Blocking webhook that shapes an inbound call's configuration in real time."
---

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](/api-reference/organizations#legacy-single-url-webhook)
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](#timeouts-and-retries)). Use this
exchange to dynamically choose a prompt, voice, and tools per call —
see the [dynamic call configuration guide](/guides/dynamic-call-config)
for the end-to-end pattern.

<Note>
  Subscribed [webhook endpoints](/webhooks/endpoints) also receive
  `telephony.incoming` / `web.incoming` — for **every** inbound call
  and web session, agent-configured or not — but those deliveries are
  fire-and-forget notifications with an `event_id`, never blocking.
  Only the legacy single-URL webhook carries the configuration
  exchange on this page. Endpoint notification shapes are in the
  [events catalog](/webhooks/events#telephony-incoming).
</Note>

## 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`, `408` or `429`
  response, 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 `4xx` response, a response over the
  [size limit](#response-size-limit), or a `2xx` whose 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 same `X-ThunderPhone-Idempotency-Key` header
  (`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`](/webhooks/call-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`.

<Warning>
  **Webhook-configured calls carry no ThunderPhone consent
  announcement.** Calls configured through this exchange bypass the
  agent-level call-start announcement and are expressly excluded from
  ThunderPhone's consent-announcement framework (Terms of Service,
  "Recording and consent" section). Your organization is solely
  responsible for every recording, monitoring, AI-participation, and
  caller-identification notice and consent required on these calls —
  the calls can still be recorded, transcribed, analyzed, and served
  by AI. Build the required disclosures into your own call flow before
  enabling this path.
</Warning>

## Request payload

For phone calls (`telephony.incoming`):

```json
{
  "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:

```json
{
  "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](/guides/embed-web-widget#browser-overrides). A
session request carrying one the key does not allow is rejected before
this webhook is called.

<Note>
  Webhook-mode widgets deliver this request to the publishable key's
  own `webhook_url` when one is set, falling back to the org-level
  webhook URL. Either way it is signed with the org webhook `secret`.
</Note>

---

## Response schema

Return a JSON object describing the agent configuration for this call.
`prompt` and `voice` are required; everything else is optional.

```json
{
  "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`](/api-reference/agents#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](/tools/overview)) |
| `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](/api-reference/agents#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](/guides/dynamic-call-config#set-the-time-zone-per-call) |
| `call_id` | integer | no | Optional echo of the request's call id; ignored |

<Note>
  Unknown top-level keys are silently **ignored** — a typo'd field
  name doesn't reject the config, it just doesn't apply. Speak-order
  and `max_hold_seconds` are not accepted here; they are only
  configurable on the [Agent](/api-reference/agents) itself.
</Note>

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](#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

<Warning>
  Configuration responses are limited to **5 MiB**. If a handler
  returns a larger response, including with a `2xx` status,
  ThunderPhone reports that the response exceeded the limit and
  rejects the call or widget session. Keep the response to the fields
  needed for call setup; host large data behind function tools or
  another service instead of embedding it in the configuration.
</Warning>

---

## Example handler

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

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

---

## Response with function tools

Attach tools so the AI can call your APIs mid-conversation:

```json
{
  "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"
        }
      }
    }
  ]
}
```

<Tip>
  Tool-endpoint requests are signed with the **same org webhook
  secret** that signed this exchange. See
  [Function Tools](/tools/overview) for the exact shape and the signed
  request format.
</Tip>

---

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

<CardGroup cols={2}>
  <Card title="telephony.complete / web.complete" icon="phone-slash" href="/webhooks/call-complete">
    The non-blocking end-of-call event.
  </Card>
  <Card title="Function Tools" icon="screwdriver-wrench" href="/tools/overview">
    Full JSON schema for `tools[]` and the signed endpoint contract.
  </Card>
  <Card title="Webhook endpoints" icon="bolt" href="/webhooks/endpoints">
    Subscribe multiple URLs to `telephony.incoming` / `web.incoming`.
  </Card>
  <Card title="Dynamic call configuration" icon="wand-magic-sparkles" href="/guides/dynamic-call-config">
    Patterns for per-caller prompts, tools, and A/B tests.
  </Card>
</CardGroup>
