Web-call API & WebSocket
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. |
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.
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", "text": "…", "done": false } | Streaming chat reply (chat/text mode). done: true on the final chunk |
ping | { "event": "ping", "t": <ms> } | Keepalive — reply with { "event": "pong", "t": <same> } |
session_end | { "event": "session_end", "reason": "…" } | Call ending; close the socket |
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 } | Send a typed message. chat: true = text-only (no TTS), replies stream back as text-response |
pong | { "event": "pong", "t": <ms> } | Keepalive answer to a server ping |
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.
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.