> ## Documentation Index
> Fetch the complete documentation index at: https://docs.voxworks.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded Web Calls

> Put a Voxworks agent on your own website — mint a single-use session on your server, connect the visitor's browser to the media edge, and run the same script, tools and handoffs as a phone call.

## Overview

An embedded web call puts your assistant on a page you host. A visitor clicks, talks, and the agent answers — with SMS, bookings and live handoff working exactly as they do on a call placed from the Voxworks app.

Two requests make it work. Your server mints a session; the visitor's browser uses it to reach the Voxworks media edge over SIP-over-WebSocket.

```text theme={null}
your server ──POST /api/v1/create-web-session──▶ api.voxworks.ai   (API key)
            ◀──── session token + SIP bootstrap ────

your page ──── SIP over WSS, audio both ways ───▶ Voxworks media edge ──▶ agent
```

Audio goes browser-to-edge directly. Nothing proxies it, and the API never sees it.

This page covers the developer integration. For web calls placed inside the Voxworks app — the **In Browser** option in the Test Call dialog — see [Web Calls](/calls/web-calls).

***

## What the session token is

`create-web-session` returns a **single-use** token, valid for about two minutes, for **one call on one script — chosen by your server, not by the page**.

The script, contact and destination are fixed when your server mints the token. A page holding it cannot point it at a different script, and cannot start a second call with it. That's what makes it safe to hand to a browser.

The connection bootstrap in the same response is not a credential and grants no access on its own — the token is what authorises the call. Treat it as configuration, not as a secret.

<Warning>
  Mint the session on your own server so your API key never reaches a browser. An API key can read your calls and contacts and start outbound ones; anything in a page bundle is public.
</Warning>

***

## 1. Find your `script_id`

Open **Call Scripts**, select the script the call should run, go to its **Settings** tab, and read the **Script UUID** field on the **Call Settings** card.

You'll also need an API key — see [API Quickstart](/api-reference/quickstart) if you haven't generated one.

***

## 2. Mint a session on your server

Check the endpoint works before wiring anything up:

```bash theme={null}
curl -X POST https://api.voxworks.ai/api/v1/create-web-session \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "script_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "participant_identity": "visitor-4821"
  }'
```

A successful call returns `201 Created`:

```json theme={null}
{
  "success": true,
  "session_id": "8f52322d-5402-469a-a042-69dac09bf375",
  "session_token": "8f52322d-5402-469a-a042-69dac09bf375.4kCQhASFG9t1_bC9Dvim7Kmg",
  "expires_at": "2026-09-02T09:46:11Z",
  "script_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "contact_id": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  "did": null,
  "max_concurrent": 10,
  "ws_url": "wss://MEDIA_EDGE_HOST",
  "sip_domain": "MEDIA_EDGE_HOST",
  "sip_username": "SIP_USERNAME",
  "sip_password": "SIP_PASSWORD",
  "target": "sip:web@MEDIA_EDGE_HOST",
  "headers": { "...": "..." }
}
```

Everything from `ws_url` down is connection bootstrap. Your page passes it straight to the browser
client in the next step — you never need to read, construct or understand any of it.

The response has no `call_id`. The call doesn't exist until the visitor actually connects; it shows
up in your call log at that point.

Now expose that as an endpoint of your own. Your page calls **your** endpoint; only your server holds the API key.

### Node — Express

```js theme={null}
import express from 'express';

const app = express();

app.post('/api/voice-session', async (req, res) => {
  const response = await fetch('https://api.voxworks.ai/api/v1/create-web-session', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.VOXWORKS_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      script_id: process.env.VOXWORKS_SCRIPT_ID,
      participant_identity: req.body?.visitorId,
    }),
  });

  res.status(response.status).json(await response.json());
});
```

### TypeScript — Next.js route handler

```ts theme={null}
// app/api/voice-session/route.ts
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const { visitorId } = await request.json().catch(() => ({}));

  const response = await fetch('https://api.voxworks.ai/api/v1/create-web-session', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.VOXWORKS_API_KEY!}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      script_id: process.env.VOXWORKS_SCRIPT_ID,
      participant_identity: visitorId,
    }),
  });

  return NextResponse.json(await response.json(), { status: response.status });
}
```

### Python — FastAPI

```python theme={null}
import os

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse

app = FastAPI()


@app.post("/api/voice-session")
async def voice_session(request: Request):
    body = await request.json()

    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.post(
            "https://api.voxworks.ai/api/v1/create-web-session",
            headers={"Authorization": f"Bearer {os.environ['VOXWORKS_API_KEY']}"},
            json={
                "script_id": os.environ["VOXWORKS_SCRIPT_ID"],
                "participant_identity": body.get("visitorId"),
            },
        )

    return JSONResponse(status_code=response.status_code, content=response.json())
```

### Python — Flask

```python theme={null}
import os

import requests
from flask import Flask, jsonify, request

app = Flask(__name__)


@app.post("/api/voice-session")
def voice_session():
    response = requests.post(
        "https://api.voxworks.ai/api/v1/create-web-session",
        headers={"Authorization": f"Bearer {os.environ['VOXWORKS_API_KEY']}"},
        json={
            "script_id": os.environ["VOXWORKS_SCRIPT_ID"],
            "participant_identity": (request.get_json(silent=True) or {}).get("visitorId"),
        },
        timeout=10,
    )

    return jsonify(response.json()), response.status_code
```

<Note>
  Mint the session when the visitor clicks, not when the page loads. The token expires in about two minutes.
</Note>

***

## 3. Connect the browser

The browser speaks SIP over a secure WebSocket. [JsSIP](https://jssip.net) handles that:

```bash theme={null}
npm install jssip
```

Fetch a session from your endpoint, then dial the media edge with the values it returned:

```js theme={null}
import JsSIP from 'jssip';

async function startCall() {
  const response = await fetch('/api/voice-session', { method: 'POST' });
  const session = await response.json();

  if (!response.ok || !session.success) {
    throw new Error(session.message || `Session endpoint returned ${response.status}`);
  }

  const audio = document.createElement('audio');
  audio.autoplay = true;
  document.body.append(audio);

  const ua = new JsSIP.UA({
    sockets: [new JsSIP.WebSocketInterface(session.ws_url)],
    uri: `sip:${session.sip_username}@${session.sip_domain}`,
    authorization_user: session.sip_username,
    password: session.sip_password,
    register: false,
    session_timers: false,
  });

  ua.on('connected', () => {
    const call = ua.call(session.target, {
      mediaConstraints: { audio: true, video: false },
      extraHeaders: Object.entries(session.headers).map(([k, v]) => `${k}: ${v}`),
      pcConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
      rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false },
    });

    // The peer connection may already exist by the time call() returns, in which
    // case the 'peerconnection' event has already fired. Check for it first or
    // the visitor gets a connected call with no audio.
    const attach = (pc) => {
      pc.addEventListener('track', (event) => {
        audio.srcObject = event.streams[0];
      });
    };

    if (call.connection) attach(call.connection);
    else call.on('peerconnection', ({ peerconnection }) => attach(peerconnection));

    call.on('confirmed', () => console.log('connected'));
    call.on('failed', (data) => console.error('call failed', data.cause));
  });

  ua.start();
  return ua;
}
```

Hang up by calling `ua.stop()`.

### A complete page

Everything above, as a page you can serve and click:

```html theme={null}
<!doctype html>
<meta charset="utf-8">
<title>Talk to the agent</title>

<button id="talk">Start call</button>
<button id="hangup" disabled>Hang up</button>
<span id="state">idle</span>

<script type="importmap">
  { "imports": { "jssip": "https://esm.sh/jssip@3.13.8" } }
</script>
<script type="module">
  import JsSIP from 'jssip';

  const talk = document.querySelector('#talk');
  const hangup = document.querySelector('#hangup');
  const label = document.querySelector('#state');
  let ua = null;

  talk.onclick = async () => {
    label.textContent = 'connecting';
    talk.disabled = true;
    hangup.disabled = false;

    const response = await fetch('/api/voice-session', { method: 'POST' });
    const session = await response.json();

    const audio = document.createElement('audio');
    audio.autoplay = true;
    document.body.append(audio);

    ua = new JsSIP.UA({
      sockets: [new JsSIP.WebSocketInterface(session.ws_url)],
      uri: `sip:${session.sip_username}@${session.sip_domain}`,
      authorization_user: session.sip_username,
      password: session.sip_password,
      register: false,
      session_timers: false,
    });

    ua.on('connected', () => {
      const call = ua.call(session.target, {
        mediaConstraints: { audio: true, video: false },
        extraHeaders: Object.entries(session.headers).map(([k, v]) => `${k}: ${v}`),
        pcConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
        rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false },
      });

      const attach = (pc) => {
        pc.addEventListener('track', (e) => { audio.srcObject = e.streams[0]; });
      };
      if (call.connection) attach(call.connection);
      else call.on('peerconnection', ({ peerconnection }) => attach(peerconnection));
      call.on('confirmed', () => { label.textContent = 'connected'; });
      call.on('ended', () => { label.textContent = 'ended'; });
      call.on('failed', (d) => { label.textContent = d.cause || 'failed'; });
    });

    ua.start();
  };

  hangup.onclick = () => {
    ua?.stop();
    talk.disabled = false;
    hangup.disabled = true;
    label.textContent = 'ended';
  };
</script>
```

The page must be served over HTTPS or from `localhost` — browsers only grant microphone access to a secure context.

### React

```tsx theme={null}
import { useCallback, useRef, useState } from 'react';
import JsSIP from 'jssip';

type CallState = 'idle' | 'connecting' | 'connected' | 'ended' | 'failed';

export function useVoxworksCall() {
  const [state, setState] = useState<CallState>('idle');
  const uaRef = useRef<JsSIP.UA | null>(null);

  const start = useCallback(async () => {
    setState('connecting');

    const response = await fetch('/api/voice-session', { method: 'POST' });
    const session = await response.json();

    if (!response.ok || !session.success) {
      setState('failed');
      return;
    }

    const audio = document.createElement('audio');
    audio.autoplay = true;
    document.body.append(audio);

    const ua = new JsSIP.UA({
      sockets: [new JsSIP.WebSocketInterface(session.ws_url)],
      uri: `sip:${session.sip_username}@${session.sip_domain}`,
      authorization_user: session.sip_username,
      password: session.sip_password,
      register: false,
      session_timers: false,
    });

    ua.on('connected', () => {
      const call = ua.call(session.target, {
        mediaConstraints: { audio: true, video: false },
        extraHeaders: Object.entries(session.headers).map(([k, v]) => `${k}: ${v}`),
        pcConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
        rtcOfferConstraints: { offerToReceiveAudio: true, offerToReceiveVideo: false },
      });

      const attach = (pc: RTCPeerConnection) => {
        pc.addEventListener('track', (e: RTCTrackEvent) => {
          audio.srcObject = e.streams[0];
        });
      };

      if (call.connection) attach(call.connection);
      else call.on('peerconnection', ({ peerconnection }: any) => attach(peerconnection));
      call.on('confirmed', () => setState('connected'));
      call.on('ended', () => setState('ended'));
      call.on('failed', () => setState('failed'));
    });

    ua.start();
    uaRef.current = ua;
  }, []);

  const hangup = useCallback(() => {
    uaRef.current?.stop();
    uaRef.current = null;
    setState('ended');
  }, []);

  return { state, start, hangup };
}
```

***

## Always configure an ICE server

The `pcConfig` block in every example above is not optional:

```js theme={null}
pcConfig: { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] },
```

Until a visitor has granted microphone permission, browsers withhold the local network details needed to set up the audio path. Without an ICE server there's nothing to fall back on and the call cannot connect.

The symptom is easy to misread: the call fails on a visitor's **first** click and works on the next one, because by then the permission grant is persistent. Configure a STUN server and it works the first time. If your visitors sit behind restrictive corporate networks, supply a TURN server here as well.

<Note>
  Ask for microphone permission on a click. Browsers only prompt in response to a user gesture, and a call started without a gesture cannot play the agent's audio back.
</Note>

***

## Request options

| Field                  | Required | What it does                                                                                                      |
| ---------------------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
| `script_id`            | yes      | Which agent script the call runs. Must belong to your team.                                                       |
| `did`                  | no       | One of your team's numbers, used as the AI-side number identity on the call record. Omit for a browser-only call. |
| `contact_id`           | no       | Attach the call to a known contact. Omitted uses your team's shared web contact.                                  |
| `participant_identity` | no       | A label for the visitor, carried through to the transcript.                                                       |
| `objects`              | no       | Objects to link to the contact, same shape as the `objects` field on `create-phone-call`.                         |

## Response fields

| Field                                                            | What it's for                                                                  |
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `session_token`                                                  | The single-use token authorising this one call. Already included in `headers`. |
| `expires_at`                                                     | When the token stops being redeemable, about two minutes after minting.        |
| `ws_url`, `sip_domain`, `sip_username`, `sip_password`, `target` | Connection bootstrap. Pass through to the browser client unchanged.            |
| `headers`                                                        | Call metadata to forward verbatim. Don't construct these yourself.             |
| `max_concurrent`                                                 | Your team's concurrent-call ceiling.                                           |

<Warning>
  Read `ws_url`, `sip_domain`, `sip_username`, `sip_password` and `target` from the response on every
  call — never hardcode them. They can change without notice, and a page pinned to stale values
  fails with nothing more than a generic connection error, which is very hard to diagnose.
</Warning>

***

## Errors worth handling

| Status | Meaning                                                                                                                    |
| ------ | -------------------------------------------------------------------------------------------------------------------------- |
| `400`  | The body isn't valid JSON, `script_id` is missing or malformed, `contact_id` is malformed, or `objects` failed validation. |
| `404`  | `script_id` — or `contact_id`, if you sent one — isn't yours.                                                              |
| `429`  | Your team is at its concurrent-call limit. The response carries `max_concurrent`.                                          |
| `500`  | The external web-call path isn't configured for this environment.                                                          |

Every error uses the standard shape:

```json theme={null}
{
  "success": false,
  "message": "script_id not found or does not belong to your team"
}
```

***

## Limits

* **The token expires in about two minutes.** Mint it when the visitor clicks, not on page load.
* **One token, one call.** Reloading the page needs a new session.
* **Concurrency is per team and shared with phone calls.** A team at its ceiling on outbound calls cannot start a web call either.
* **The page must be a secure context** — HTTPS, or `localhost` while developing.

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Web Calls" href="/calls/web-calls">
    How web calls behave once connected — the Call Log, web contacts, handoff, and recordings.
  </Card>

  <Card title="API Quickstart" href="/api-reference/quickstart">
    Generate a key, create a contact, and run your first request against the API.
  </Card>

  <Card title="Live handoff" href="/scripts/live-handoff">
    Hand a web call to a human, dialled on one of your team's numbers.
  </Card>

  <Card title="Rate Limits & Request Limits" href="/api-reference/rate-limits">
    Rate-limit buckets, the 429 headers, and the request body size cap.
  </Card>
</CardGroup>
