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:

CredentialCall it fromUse when
Client token Authorization: Bearer …The browserYour own web/mobile app. This is the right one for client-side.
API key X-API-KeyYour server onlyYou mint the session server-side and hand {sessionId, websocketUrl} to the client.
User JWT Authorization: Bearer … + X-Org-IdYour server / dashboardActing 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.

FieldBehaviour with a client token
agentIdForced to the token's agent. A holder can't switch to a different or more expensive agent.
variablesReplaced by the token's variables (when it has any). A holder can't inject or overwrite prompt context.
userIdForced 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

FieldTypeNotes
agentIdUUIDRequired for mode: "agent" (the default)
modestring"agent" (AI talks) or "manual" (bridge a browser ↔ carrier call)
userIdUUIDOptional attribution
bearerstringOptional bearer token forwarded to the LLM / the agent's HTTP tools
payloadobjectOptional custom data passed to the LLM
fromNumber, toNumberstringRequired for mode: "manual" (E.164)
variablesobjectOptional { "name": "value" } map for the agent's context variables (agent mode)
identifierstringOptional trusted unique id for the caller. Injected into tool calls when the agent has caller identity enabled
firstResponsestringOptional (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

EventShapeMeaning
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

EventShapeMeaning
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)

  1. POST /api/sessions/init-web-call → get websocketUrl.
  2. Open the WebSocket. On open (or on ready), send { "event": "start", "sessionId": … }.
  3. On connected, capture mic audio, resample to μ-law 8 kHz, and stream media frames.
  4. Play incoming media frames; render transcript events as needed.
  5. Answer any ping with a matching pong so the socket isn't dropped as idle.
  6. 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.