---
title: "Embed the web widget"
description: "Drop a voice agent onto your marketing or support site with a single script tag — visitors talk to it straight from the browser, no phone number required."
---

The web widget gives your site visitors a click-to-talk conversation
with an AI agent, using the browser's microphone. It's a separate
JavaScript / React SDK with its own [SDK reference](/widget/overview)
— this guide focuses on the ThunderPhone-side setup the widget needs.

<Note>
  You can do all of this without cURL: the **Web Widgets** dashboard
  page (`/dashboard/web-widgets`) creates the widget, sets its mode
  and agent, manages allowed domains, and hands you the embed snippet.
</Note>

## Prerequisites

<Steps>
  <Step title="Create an agent">
    The agent whose prompt and voice will run the widget session. Set
    `widget_enabled: true` (the default).
  </Step>
  <Step title="Decide the routing mode">
    - `mode="agent"` — one static agent per key. Simplest.
    - `mode="webhook"` — your server picks the agent per visitor via a
      [`web.incoming` webhook](/webhooks/call-incoming). Use this for
      logged-in users, A/B tests, or per-page routing.
  </Step>
  <Step title="List the allowed domains">
    Name every hostname that will embed the widget. Browsers send the
    page's origin with each session request, so the list stops your key
    from working when someone copies it onto another site. It is not
    authentication: a script outside a browser can send any `Origin`
    header. `localhost` is not allowed automatically in production; to
    test locally against the production API, add `localhost` to the
    list.
  </Step>
  <Step title="Choose the browser overrides">
    Decide which session settings your page may send. New keys accept
    none; see [Browser overrides](#browser-overrides).
  </Step>
</Steps>

## Create a publishable key

<CodeGroup>
```bash Static agent
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":            "Marketing site (prod)",
    "mode":            "agent",
    "agent_id":        12,
    "allowed_domains": ["example.com", "*.example.com"],
    "allowed_overrides": ["context"]
  }'
```

```bash Dynamic via webhook
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":            "Support (dynamic)",
    "mode":            "webhook",
    "webhook_url":     "https://example.com/thunderphone/widget-hook",
    "allowed_domains": ["support.example.com"]
  }'
```
</CodeGroup>

The response includes a `key` starting with `pk_live_...`. **Publishable
keys are public by design** — safe to ship in your front-end bundle.
See [Publishable keys reference](/api-reference/publishable-keys) for
all fields.

<Warning>
  `allowed_domains` must contain at least one entry. `*.example.com`
  matches subdomains (e.g. `api.example.com`) but **not** the bare
  domain. Bare wildcards like `*` or `*.*` are rejected.
</Warning>

## Drop the widget on your site

Three integration options are covered in the
[widget SDK docs](/widget/overview):

<CardGroup cols={3}>
  <Card title="React component" icon="react" href="/widget/react">
    `<ThunderPhoneWidget publishableKey="pk_live_..." />`.
  </Card>
  <Card title="Headless hook" icon="circle-nodes" href="/widget/headless-hook">
    `useThunderPhone()` for custom UIs.
  </Card>
  <Card title="CDN script tag" icon="code" href="/widget/cdn-script-tag">
    `ThunderPhone.mount({...})` for non-bundler sites.
  </Card>
</CardGroup>

All three accept the same `publishableKey` and render the mic button
plus the in-call audio element.

## Browser overrides

The widget can send four per-session settings from the page:

| Setting | Widget prop | What it changes |
|---------|-------------|-----------------|
| `context` | `context` | Text from the page, appended to the agent prompt as reference |
| `variables` | none (raw `POST /v1/widget/session` only) | Values for `{{variable}}` placeholders in the prompt |
| `voice` | `voice` | The agent's voice for this session |
| `language` | `language` | The language the session starts in |

Your publishable key is public, so anyone who has it can send these
settings, including from a script that fakes your domain. Each one is
therefore off until you allow it on the key: tick it under **Browser
overrides** when you create or edit the widget in the dashboard, or set
`allowed_overrides` through the
[Publishable keys API](/api-reference/publishable-keys). Allow only what
your page actually sends.

A session request that carries a setting the key does not allow fails
with `400` and names it:

```json
{
  "error": "override_not_allowed",
  "fields": ["voice"],
  "message": "This publishable key does not allow the browser to set `voice`. ..."
}
```

The check applies in both modes. In webhook mode an allowed setting is
also forwarded to your `web.incoming` webhook (`context` arrives as
`website_context`). Keys created before this setting existed allow all
four, so existing embeds keep working; turn off what you don't use.

Widget `context` is truncated at 12,000 characters (roughly 3,400
tokens of typical English text) and counts toward the
[prompt-size surcharge](/guides/billing-and-topups#prompt-size-surcharge).
The agent is told to treat it as page content, not as instructions.

## Widget-mode webhooks

When `mode="webhook"`, ThunderPhone calls your `webhook_url` on every
session start with a `web.incoming` payload. Return the agent
configuration you want to run for that visitor — it follows the same
[response schema](/webhooks/call-incoming#response-schema) as phone
calls:

```json
{
  "prompt":  "You are a VIP concierge for Jane Doe.",
  "voice":   "john",
  "product": "storm-base",
  "tools":   [ /* per-customer tools */ ]
}
```

You can mix context from your own session (which customer is browsing,
which page they're on) into the prompt, and swap agents per rollout.

## Observe the sessions

Widget sessions show up in
[`GET /v1/calls`](/api-reference/calls#list-calls) with
`direction="widget"` — same transcript, recording, grading, and
billing as phone calls. Filter by `direction` to build a widget-only
dashboard.

---

## Next steps

<CardGroup cols={2}>
  <Card title="Widget SDK reference" icon="window-maximize" href="/widget/overview">
    React / hook / CDN integration details.
  </Card>
  <Card title="Dynamic per-call config" icon="bolt" href="/guides/dynamic-call-config">
    Implement the `mode="webhook"` flow end-to-end.
  </Card>
  <Card title="Publishable keys reference" icon="key" href="/api-reference/publishable-keys">
    Every field on the key resource.
  </Card>
  <Card title="Mic sessions API" icon="microphone" href="/api-reference/mic-sessions">
    Skip the widget; drive LiveKit directly for custom UIs.
  </Card>
</CardGroup>
