# Talk from a browser

A call button on your website, or a conversation inside your app. One route on your server, one tag on your page, and your API key never leaves your machines.

## Why this needs its own step

Everything else in this API is server-to-server: your backend holds a key and makes requests with it. A browser cannot do that. Anything your page can read, everyone who loads your page can read, so an API key in JavaScript is an API key you have published.

So the browser gets a different credential, and this page is how you mint one.

## Step 1 · Mint a call token on your server

One endpoint. It creates a call and returns a credential good for that one call.

`POST /v1/realtime/tokens`

**Shell**

```bash
curl -s -X POST https://voice.sphoro.com/v1/realtime/tokens \
  -H "Authorization: Bearer $SPHORO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"agent_id": "agt_5c6388e49199af91775dd45b"}'
```

**Response201 Created**

```json
{
  "object": "realtime_token",
  "url": "wss://voice.sphoro.com/v1/realtime?call_id=call_29245f26&access_token=eyJhbGci...",
  "call_id": "call_29245f26bd9e386f65e640eb",
  "agent_id": "agt_5c6388e49199af91775dd45b",
  "expires_at": "2026-08-27T18:34:14Z",
  "subprotocol": "voiceai.realtime.v1",
  "agent": {"id": "agt_5c6388e49199af91775dd45b", "name": "Acme Dental Reception", "language": "en-IN"}
}
```

`url` is the only field a browser needs — the credential is already in it. The rest is there so you do not have to take it apart: `call_id` to join up with your own records, `agent` to label the call in your interface without a second request needing a stronger credential.

| Field | Type | Description |
| --- | --- | --- |
| `agent_id` required | string | The agent to talk to. |
| `ttl_seconds` optional | integer | How long the token may be used to *open* the socket. 1–3600, default 300. It does not bound the call: once the handshake is done, the socket holds the session. |
| `variables` optional | object | Seeds the agent's context — a name, an order number your page already knows — so the conversation does not open by asking for it. |
| `metadata` optional | object | Your own string key/values, stored with the call. |
| `project_id` optional | string | Attributes the call to a cost centre, overriding the agent's own. |
| `language` optional | string | Opens this call in one of the agent's languages. Defaults to the first. |

## Step 2 · Wrap it in a route of your own

Your page calls your server; your server calls us. Two lines matter: the header carrying your key, and your own authentication in front of it.

```javascript
app.post('/api/voice-token', async (req, res) => {
  // Your existing session check. Do not skip this: without it, anyone who finds this URL
  // can place calls on your account, and calls cost money. Rate-limit it too.
  const user = await requireSignedIn(req)

  const minted = await fetch('https://voice.sphoro.com/v1/realtime/tokens', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.SPHORO_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      agent_id: 'agt_5c6388e49199af91775dd45b',
      variables: { name: user.firstName },
      metadata:  { user_id: user.id },
    }),
  })

  // Forwarded unchanged. Its "url" is what the browser connects to.
  res.status(minted.status).json(await minted.json())
})
```

> **Never send your API key to the page.** Not in a script tag, not in a config endpoint, not "just for the prototype". A call token names one call, carries one scope, expires in minutes, and is refused on every path but the realtime socket. An API key is your whole account, for as long as it exists.

## Step 3 · Put it on the page

The fastest version is one tag. It renders a call button, a live transcript, mute and hang-up, in a shadow root your stylesheet cannot reach into.

```html
<script type="module"
        src="https://voice.sphoro.com/v1/embed/widget.js"
        data-token-url="/api/voice-token"
        data-label="Talk to us"
        data-accent="#4f46e5"></script>
```

> `type="module"` is required — the script imports the client beside it. Without it the browser reports *Cannot use import statement outside a module* and nothing renders.

For an interface of your own, the same client is an npm package:

```javascript
import { startCall } from '@sphoro/voice-web'

const call = await startCall({ tokenUrl: '/api/voice-token' })

call.on('transcript', ({ speaker, text }) => append(speaker, text))
call.on('activity', (a) => setIndicator(a))   // listening | thinking | speaking
call.on('state', (s) => { if (s === 'ended') close() })

hangUpButton.onclick = () => call.hangUp()
```

`startCall` resolves once the session is live, so the line after it can render a call that is genuinely running.

## What the client is doing for you

The realtime socket is documented and you can speak it yourself. Most of the work is not the protocol, though — it is the audio either side of it, and every item here was a bug in this client before it was a feature of it.

- **Playback has to be paced.** Audio written to a sink as fast as it arrives finishes instantly: a nine-second greeting handed over in three milliseconds ends the speaking state before the caller has heard a word, and barge-in — which only applies while the agent is speaking — silently stops working.
- **Interrupting needs a local flush.** Telling the server to abandon the turn is half of it; the agent keeps talking for as long as the already-buffered audio lasts. Dropping that buffer is the half the caller actually hears.
- **Filter before you decimate.** Going from the device's 48 kHz to the 8 kHz the media path speaks by dropping samples folds everything above 4 kHz back into the voice. It does not sound muffled; it sounds like a robot gargling.
- **The audio worklet can hang.** Not fail — hang, with its promise never settling, on some environments. A client that waits for it plays nothing, hears nothing, and reports no error.
- **The microphone may be declined.** Playback is built before capture, so a refused permission prompt leaves a call the visitor can still hear, read, and type into.

## A telephone in someone else’s product

Everything above is a visitor talking to an *agent*. This is the other product: your own staff talking to *customers*, from inside the CRM they already work in — outgoing calls from a click-to-dial button, and incoming calls ringing on their screen.

One tag, and no backend work at all:

```html
<script src="https://voice.sphoro.com/v1/embed/dialer.js"></script>
```

It docks the operator console into the corner of the page. Your telecaller signs in once with their own account, and from then on it rings for incoming calls and places outgoing ones. Wire your own call button to it with one line:

**Node**

```js
SphoroDialer.dial('+919109099359')
```

Safe to `await` and safe to call after awaiting something else — the panel is an iframe, so there is no popup for a browser to block. A number handed over before the console has finished loading is queued and delivered the moment it is listening.

| Field | Type | Description |
| --- | --- | --- |
| `SphoroDialer.dial(number)` optional |  | Ring an E.164 number, showing the dialler if it is not up. |
| `SphoroDialer.compose(number)` optional |  | Put a number on the keypad without ringing it. |
| `SphoroDialer.open() / .hide()` optional |  | Show or hide the panel. Open it when your app loads its shell, so a telecaller has it up to receive incoming calls. |
| `SphoroDialer.popOut()` optional |  | Move the dialler into its own window. Call it from a click; it is the fallback where a panel cannot hold a session. |
| `SphoroDialer.on(event, fn)` optional |  | `ready`, `closed`, and `state` — which is `ready` or `signed-out`. |
| `data-button="false"` optional |  | On the script tag, when your product draws its own call button and wants nothing in the corner. See also `data-width`, `data-position`, `data-accent`, `data-title`. |

> **The deployment has to allow your site first.** Set `CONSOLE_EMBED_ORIGINS` to the exact origins that may embed the console — `https://crm.example.com`, comma-separated — and restart. Empty is the default and means nobody: framing a page whose main control places telephone calls is a capability, and one that is on by default is one nobody chose. A site that is not on the list gets a blank panel and a message saying so, because the browser refuses the frame outright.

> An embedded console is a third-party context, so its session is issued as a *partitioned* cookie — browsers keep a separate jar per embedding site, which means the handset signed into from one CRM cannot be reached from another. Safari implements no such thing and refuses third-party cookies outright; the panel notices, says so, and offers the window instead, which is first-party and works everywhere.

> Calls are placed as whoever is signed into the panel, so each telecaller needs their own account here — that is what makes the number a customer sees, and calls back on, their line rather than a shared one. Outgoing needs a carrier that can dial out; incoming needs one of your numbers pointed at a line a person answers.

## When it does not work

| Field | Type | Description |
| --- | --- | --- |
| `Connects, no sound` optional |  | Almost always an insecure context. Browsers refuse the microphone outside `https://`, `localhost` aside — which is why it works on your laptop and not on staging. |
| `Nothing renders` optional |  | The script tag is missing `type="module"`, or `data-token-url`. The console names whichever it is. |
| `403 on your own route` optional |  | Your session check refused. That is the route working. |
| `422 naming agent_id` optional |  | The agent does not exist, or belongs to another account. |
| `403 on the socket, naming the token` optional |  | A call token was used somewhere other than the realtime socket, or for a second call. Mint one per call, from your server. |

> A strict Content-Security-Policy needs three entries: `script-src` and `worker-src` for this origin, and `connect-src` for the socket. Without `worker-src` the call still works — the client falls back to an older audio graph and says so in the console.
