Guides
Web voice sessions
Run a voice agent inside your web app: your server mints a session, the browser connects to wss://rtc.vollo.io with the LiveKit JS SDK, and the same published workflows answer — no phone number involved.
The token flow#
Browsers never hold your API key. The flow is always three steps:
- The browser asks your backend to start a voice session.
- Your backend calls
POST /api/v1/sessionswith its API key (scopecalls:write) and returns the short-livedjoin_tokento the browser. - The browser connects to
wss://rtc.vollo.iowith that token. An agent worker joins the same room and the workflow starts.
Minting a session#
curl -X POST https://api.vollo.io/api/v1/sessions \
-H "Authorization: Bearer pk_live_8f3KJd92mA4qL7Zx1RttVWpc" \
-H "Content-Type: application/json" \
-d '{
"project_id": "9b2e7c1a-4d3f-4e8a-9c5b-2f7d1e6a8b3c",
"workflow_id": "7f3a9d2e-8c1b-4f6a-b5d4-e2c9a1f7b8d0",
"variables": { "customer_name": "Lina" },
"metadata": { "user_id": "u_582" }
}'{
"data": {
"id": "5e8b3c1d-9f2a-4d7e-b6c8-1a4f7d2e9b3c",
"channel": "web",
"status": "created",
"join_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.…",
"rtc_url": "wss://rtc.vollo.io",
"expires_at": "2026-08-18T09:22:44Z",
"workflow_version": 3,
"created_at": "2026-08-18T09:12:44Z"
}
}Notes:
join_tokenis a single-use, room-scoped JWT valid for 10 minutes (untilexpires_at) — long enough to hand to the browser and connect, useless for anything else.variablesseeds workflow variables;metadatais your opaque data, echoed back in webhooks andGET /sessions/{id}.- The latest published version of the workflow runs. Drafts run only in the builder's test panel.
Connecting from the browser#
Install the LiveKit client (npm install livekit-client), then:
import { Room, RoomEvent, Track } from 'livekit-client';
// 1. Get a session from your own backend (which calls POST /sessions)
const res = await fetch('/api/voice-session', { method: 'POST' });
const { join_token, rtc_url } = await res.json();
// 2. Connect to Vollo's media plane
const room = new Room();
await room.connect(rtc_url, join_token); // wss://rtc.vollo.io
// 3. Play the agent's audio when it arrives
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
track.attach(); // creates and plays an <audio> element
}
});
// 4. Publish the microphone — the workflow starts once audio is up
await room.localParticipant.setMicrophoneEnabled(true);
// Later: hang up from the client side
// await room.disconnect();Call room.connect() from a user gesture (a “Start call” button). Browsers block both microphone capture and audio autoplay outside user-initiated contexts.
Session lifecycle#
| Status | Meaning |
|---|---|
created | Minted, waiting for the browser to connect. Expires unused at expires_at. |
active | Participant connected; the workflow is executing. |
ended | Terminal. Reached when the workflow hits Hangup/End, the browser disconnects, the session hits the workflow's max_duration_seconds, or you force it with DELETE /sessions/{id}. |
expired | Terminal. Never connected before expires_at. |
On every transition to ended, a session.ended webhook fires with duration and end reason, followed by workflow.completed or workflow.failed for the execution.
Transcripts#
Every session (web and phone) produces a conversation — the ordered transcript of user, assistant, system, and tool messages with per-message latency. Fetch it after the session ends:
curl "https://api.vollo.io/api/v1/conversations?session_id=5e8b3c1d-9f2a-4d7e-b6c8-1a4f7d2e9b3c" \
-H "Authorization: Bearer pk_live_8f3KJd92mA4qL7Zx1RttVWpc"{
"data": [
{
"id": "0c3f7a9e-2d5b-4c8e-9a1f-6b4d8e2c5a7f",
"session_id": "5e8b3c1d-9f2a-4d7e-b6c8-1a4f7d2e9b3c",
"messages": [
{ "sequence": 1, "role": "assistant", "content": "Hi Lina! How can I help today?", "spoken_at": "2026-08-18T09:13:02Z", "latency_ms": 420 },
{ "sequence": 2, "role": "user", "content": "I want to check my order status.", "spoken_at": "2026-08-18T09:13:09Z" }
]
}
]
}Transcripts stream into the conversation while the session is live, so polling mid-call works too. For push-based delivery, listen for session.ended and fetch once.
Testing tip#
The builder's test-call panel uses this exact mechanism with channel: "test" — sessions it creates appear in GET /sessions like any other, which makes end-to-end debugging (webhooks included) easy before you write a line of frontend code.