Web-call API & WebSocket
The web-call API runs a conversation in the browser over a WebSocket — the visitor's microphone streams to the agent and the agent's voice streams back, with no phone number involved. You start a session over REST, then connect a WebSocket to the returned URL. The same socket can also run a text conversation (no audio) if you just want a chat surface bound to a live session.
For a complete, copy-pasteable recipe see Programmatic web calls. If you only need text and don't want to manage a WebSocket at all, use the simpler Chat API instead.
Start a session
POST /api/sessions/init-web-call
This endpoint accepts three credentials, and which one you use determines where the call can legitimately be started from:
| Credential | Call it from | Use when |
|---|---|---|
Client token Authorization: Bearer … | The browser | Your own web/mobile app. This is the right one for client-side. |
API key X-API-Key | Your server only | You mint the session server-side and hand {sessionId, websocketUrl} to the client. |
User JWT Authorization: Bearer … + X-Org-Id | Your server / dashboard | Acting as a signed-in user. |
Never put an API key in browser code. It's a bearer credential for your whole organization — every agent, every call record, every number. Anything shipped to a browser is public. Mint a client token on your server instead.
Server-side (API key):
curl -X POST https://api.telenow.ai/api/sessions/init-web-call \
-H "x-api-key: vai_live_…" \
-H "Content-Type: application/json" \
-d '{ "agentId": "…" }'
Browser (client token) — the body can be empty, because everything that matters is already baked into the token:
curl -X POST https://api.telenow.ai/api/sessions/init-web-call \
-H "Authorization: Bearer eyJhbGciOi…" \
-H "Content-Type: application/json" -d '{}'
What a client token overrides
When you authenticate with a client token, the fields below are read from the token and whatever you send in the body is discarded. This is deliberate: the body is attacker-controlled once the token is in a browser.
| Field | Behaviour with a client token |
|---|---|
agentId | Forced to the token's agent. A holder can't switch to a different or more expensive agent. |
variables | Replaced by the token's variables (when it has any). A holder can't inject or overwrite prompt context. |
identifier | Replaced by the token's callerIdentity.identifier. With none pinned, the call has no identifier — the body's is never used, so a holder can't make a tool or a pre-call lookup treat them as another customer. |
userId | Forced to the user whose API key minted the token. |
mode: "manual" | Rejected with 403 — a client token can never place an outbound PSTN call. |
Fields the token doesn't pin (bearer, payload, firstResponse, clientRecording) are still read from the body. Treat them as client-supplied and don't let a tool trust them for authorization.
Request fields
| Field | Type | Notes |
|---|---|---|
agentId | UUID | Required for mode: "agent" (the default) |
mode | string | "agent" (AI talks) or "manual" (bridge a browser ↔ carrier call) |
userId | UUID | Optional attribution |
bearer | string | Optional bearer token forwarded to the LLM / the agent's HTTP tools |
payload | object | Optional custom data passed to the LLM |
fromNumber, toNumber | string | Required for mode: "manual" (E.164) |
variables | object | Optional { "name": "value" } map for the agent's context variables (agent mode) |
identifier | string | Optional trusted unique id for the caller. Injected into tool calls when the agent has caller identity enabled |
firstResponse | string | Optional (agent mode). Override the agent's opening line for this session — the agent speaks this text first instead of its saved opener. Ideal for a personalized greeting like "Hi {name}!"; variables resolve against variables. Same field as on initiate-call |
Response
{
"success": true,
"data": {
"sessionId": "…",
"status": "active",
"websocketUrl": "wss://api.telenow.ai/ws/web-agent",
"embedSnippet": "<script>…</script>"
}
}
Connect a browser WebSocket to websocketUrl, then send { "event": "start", "sessionId": "…" } as the first frame (see the WebSocket protocol below) — the API key stays on your server; the browser only ever holds the opaque sessionId.
Manual mode (softphone / click-to-call)
Pass mode: "manual" to place a human telephony call instead of an AI one — the bridge behind the dashboard softphone, exposed for embedding click-to-call in your own CRM. Telenow rings toNumber from fromNumber (a caller-ID your org owns — Numbers, BYOC, or a SIP trunk) and bridges the carrier leg to the browser that connects the returned websocketUrl. No agent, STT, LLM, or TTS runs.
curl -X POST https://api.telenow.ai/api/sessions/init-web-call \
-H "x-api-key: vai_live_…" \
-H "Content-Type: application/json" \
-d '{ "mode": "manual", "fromNumber": "+15550001111", "toNumber": "+15551234567" }'
With an API key, fromNumber is required; on a user JWT it defaults to the member's allocated number. The response carries the carrier-call fields:
{
"success": true,
"data": {
"sessionId": "…",
"status": "active",
"callId": "…",
"callMode": "manual",
"fromNumber": "+15550001111",
"toNumber": "+15551234567",
"websocketUrl": "wss://api.telenow.ai/ws/web-agent"
}
}
Hand { sessionId, websocketUrl } to the client SDK (TelenowCall({ session })) — that browser becomes the rep's mic + speaker. The call is recorded server-side and the same webhooks (call.started, call.ended, recording.ready) fire as for AI calls, so the recording URL and call data land in your CRM automatically. The sessionId also works with POST /api/sessions/{id}/transfer and DELETE /api/sessions/{id}. Works on every carrier: Plivo, Twilio, Vobiz, Exotel, Vonage, Tata Tele Smartflo, and SIP trunks. The simplest way to drive it is the backend SDK (createManual / create_manual_call).
Errors mirror the outbound-call gates: 403 if toNumber is on the Do-Not-Call list, 429 at quota/concurrency caps, 404 if fromNumber isn't owned by your org, 400 with a code when the carrier refuses for something on the account (empty wallet, incomplete KYC, missing App ID — see outbound-call errors), and 502 when the carrier itself is unwell.
WebSocket protocol
Messages are JSON text frames with an event field. Audio is base64-encoded μ-law, 8 kHz, mono, sent in small (~20 ms) frames. One message may be at most 2 MiB; a larger one closes the socket.
Server → client
| Event | Shape | Meaning |
|---|---|---|
ready | { "event": "ready" } | Socket is up; send start |
connected | { "event": "connected" } | Session bound; you may begin streaming |
answered | { "event": "answered" } | (manual mode) the callee picked up |
media | { "event": "media", "data": "<b64>", "format": "mulaw", "sampleRate": 8000 } | Agent/remote audio to play |
transcript | { "event": "transcript", "role": "user"|"assistant", "text": "…", "isFinal": true } | Live transcript turn (the agent's opening greeting also arrives here) |
text-response | { "event": "text-response", "role": "assistant", "delta": "…", "done": false } | Streaming chat reply (chat/text mode): each chunk's new words are in delta. The final chunk is { "event": "text-response", "text": "…", "done": true }, with the whole reply in text |
reply-ui | { "event": "reply-ui", "role": "assistant", "ui": { "kind": "options", "shownAs": "buttons", "options": [{ "id": "…", "title": "…" }] } } | Sent right after a reply's final text-response when the agent attached options the user taps, or one link button (only on agents with Buttons and links in chat replies on). ui is the same object as the Chat API's. Draw it under that reply; when the user taps an option, send its title as text with its id as optionId. A ui of kind: file is a file from the library with a signed url: show an image, or offer the download |
text-rejected | { "event": "text-rejected", "reason": "too_long", "maxChars": 4096 } | Your text was refused before the agent saw it, and nothing was billed: it is longer than maxChars characters (Unicode code points). Shorten it and send it again. reason: "busy" (no maxChars): too many messages are already waiting behind a file that is still being heard; send it again once the agent replies |
attachment-heard | { "event": "attachment-heard", "attachmentId": "…", "kind": "audio", "transcription": "done", "transcript": "…" } | (public widget chat) The file your text named is with the agent, just before its reply. transcription: done (with transcript, what the voice note said), no_speech, too_long, failed, or none for an image or a document |
attachment-rejected | { "event": "attachment-rejected", "attachmentId": "…", "reason": "unknown" } | The file your text named was not taken: unknown (not a file this session uploaded, or one already sent) or not_accepted (this session takes no files). Its words, if any, still reach the agent |
ping | { "event": "ping", "t": <ms> } | Keepalive — reply with { "event": "pong", "t": <same> } |
session_end | { "event": "session_end", "reason": "…" } | Call ending; close the socket |
context_ack | { "event": "context_ack", "key": "cart", "noteId": "…", "delivery": "next_turn" } | Your contextual_update was taken. delivery: next_turn, speaking_now (respond: "when_idle" into a free line) or held (the agent is on hold or a transfer is under way). Every frame gets exactly one reply, in the order you sent |
context_rejected | { "event": "context_rejected", "key": "cart", "reason": "disabled" } | It was refused: disabled, too_large (with maxChars — Unicode code points that fit right now), rate_limited, empty, engine_unsupported, no_agent, not_live (also for a frame sent before start) or invalid_respond |
activity_ack | { "event": "activity_ack", "nextCheckinInMs": 20000 } | Your user_activity restarted the check-in — in a wait the caller asked for ("one moment"), that wait's own clock. nextCheckinInMs: when the agent would speak up unprompted; null when nothing is armed right now (try again once the conversation moves on) |
activity_rejected | { "event": "activity_rejected", "reason": "not_live" } | It was refused: not_live (the call has ended, or the frame came before start) or no_agent (no agent is on this call) |
Client → server
| Event | Shape | Meaning |
|---|---|---|
start | { "event": "start", "sessionId": "…" } | Bind the socket to your session — send first |
media | { "event": "media", "data": "<b64 μ-law 8k>" } | Microphone audio frame (voice mode) |
text | { "event": "text", "text": "…", "chat": true, "optionId": "…" } | Send a typed message. chat: true = text-only (no TTS), replies stream back as text-response. optionId (optional, chat mode): the user tapped an option a reply-ui offered; the agent is told which. An id the session did not offer is ignored. Over the length limit it gets text-rejected instead. In chat mode each message is checked against the account's spending, like each WhatsApp message: if the account can no longer spend, the chat ends with session_end. Add "attachmentId": "…" to send a file you uploaded (see Files and voice notes in chat); text may then be empty |
dtmf | { "event": "dtmf", "digit": "1" } | A keypad key — 0–9, * or #, one per message. The agent receives it exactly like a phone caller's keypress: keypad menus route on it, number-entry steps collect it |
pong | { "event": "pong", "t": <ms> } | Keepalive answer to a server ping |
contextual_update | { "event": "contextual_update", "text": "…", "key": "cart", "respond": "none" } | A note for the agent — what the user is looking at, what is in their cart. Never a turn: the agent uses it from its next reply. key (optional) replaces an earlier note from this app with the same key (never one from your backend). respond (optional): none (default) or when_idle — the agent speaks up about it once the line is free (in chat mode when_idle works like none: the agent's next reply tells the user). Refused (disabled) unless the agent accepts notes from the caller's app — sessionConfig.liveContext.acceptClientNotes: true, or "Notes from the caller's app" in the builder; the agent is told these notes are unverified. Send well-formed text: half an emoji (a lone UTF-16 surrogate) makes the frame invalid JSON, and it gets no reply. See Live context notes |
user_activity | { "event": "user_activity" } | The user is still here, just busy — restarts the agent's silence check-in so it doesn't ask "are you still there?". Never makes the agent speak |
Flow (voice)
POST /api/sessions/init-web-call→ getwebsocketUrl.- Open the WebSocket. On open (or on
ready), send{ "event": "start", "sessionId": … }. - On
connected, capture mic audio, resample to μ-law 8 kHz, and streammediaframes. - Play incoming
mediaframes; rendertranscriptevents as needed. - Answer any
pingwith a matchingpongso the socket isn't dropped as idle. - On
session_end, stop and close.
Barge-in is built in: when the user speaks while the agent is talking, the agent stops and listens — your client just keeps streaming mic frames.
Flow (text only)
If you only want chat, skip the audio: after start/connected, send { "event": "text", "text": "…", "chat": true } and read the streamed text-response chunks. For most chat use cases the stateless Chat API is simpler — no socket to keep alive.
Files and voice notes in chat
On the public widget (sessions started with POST /api/public/widget/{slug}/session), a customer can send a voice note, a picture (PNG, JPEG, GIF, WebP), a PDF, or a Word or Excel file (DOCX, XLSX). The agent hears a voice note through its own speech-to-text provider, as on WhatsApp, and is told when an image or a document arrives (it cannot open them yet). Not on a realtime (speech-to-speech) agent.
-
The session reply carries
uploads:nullwhen files are not taken (a realtime agent), otherwise the limits to check before sending:"uploads": { "maxVoiceNoteSecs": 600, "maxBytes": { "audio": 5242880, "image": 5242880, "document": 10485760 }, "maxFiles": 20, "accept": "audio/*,image/png,…" } -
Upload the file over HTTP, not the socket:
POST /api/public/widget/{slug}/session/{sessionId}/attachments, the file itself as the body, its name percent-encoded inX-File-Name. No API key; the session id is the credential, and the page's origin must be one the widget allows. The type is decided from the file's bytes, whateverContent-Typesays. The answer comes at once:{ "success": true, "data": { "attachmentId": "…", "kind": "audio", "mime": "audio/webm", "fileName": "voice-note.webm", "sizeBytes": 48211 } }Refusals:
413too large (the message names the limit),400withcode: "unsupported_type"or"empty_file",429too many (this chat's files, this connection's uploads a minute, or the account's uploads today;Retry-Aftersays when),409a realtime agent,403the account cannot spend (the chat ends),404the chat is not open,503busy, try again shortly. -
Send
{ "event": "text", "text": "<their words, or empty>", "chat": true, "attachmentId": "…" }on the socket. You getattachment-heard, then the reply astext-response. A typed message sent after it waits until it is answered, so replies come in the order sent.
Limits are the platform's settings (defaults shown above), plus a cap on files per chat and per account per day. A voice note is billed as speech-to-text at the file rate, plus the usual chat turn. Files appear in call history, and follow the account's retention window.
Embedding instead
If you don't want to build the audio plumbing, use the hosted widget — a one-line script tag does all of the above. See Embed the widget. To start sessions for the anonymous public widget (no API key), use POST /api/public/widget/{slug}/session instead of init-web-call; both return the same websocketUrl shape and speak the same protocol above.