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.
/v1/realtime/tokenscurl -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"}'{
"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_idrequired | string | The agent to talk to. |
ttl_secondsoptional | 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. |
variablesoptional | 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. |
metadataoptional | object | Your own string key/values, stored with the call. |
project_idoptional | string | Attributes the call to a cost centre, overriding the agent's own. |
languageoptional | 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.
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())
})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.
<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:
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:
<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:
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. |
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.When it does not work
| Field | Type | Description |
|---|---|---|
Connects, no soundoptional | 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 rendersoptional | The script tag is missing type="module", or data-token-url. The console names whichever it is. | |
403 on your own routeoptional | Your session check refused. That is the route working. | |
422 naming agent_idoptional | The agent does not exist, or belongs to another account. | |
403 on the socket, naming the tokenoptional | A call token was used somewhere other than the realtime socket, or for a second call. Mint one per call, from your server. |
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.