Live Connect
Stream live calls into Callens from your phone system — one WebSocket per call.
Live Connect is how your phone system streams live calls into Callens: a SIPREC receiver, or a bridge you run between your phone system and Callens. You send each call's audio and a few lifecycle events. Callens transcribes the call and shows it, as it happens, to the agents who are on it.
You never talk to a speech provider and never hold a Callens user's credentials. You hold one thing: a live-source credential, minted in the Callens dashboard under Settings → Live sources.
This page is the protocol. For who does what, in order — from switching it on to an agent's first live call — see Setting it up.
Live Connect is turned on per organization; until it is turned on for yours, a call is refused with
not_in_plan. The address to connect to is also shown in Settings → Live sources.
Connect
GET wss://live.callens.io/v1/connect
Authorization: Bearer clkl_{tenant_id}_{secret}- One WebSocket per call. TLS is required.
- No
Authorizationheader is refused with HTTP401before the upgrade. Any other kind of Callens credential is refused atstartwithunauthorized. - The gateway pings every 20 seconds. Answer pings; every WebSocket library does.
- Text frames are UTF-8 JSON, at most 16 KB. Binary frames are audio, at most 64 KB.
Start the call — within 10 seconds
The first text frame must be start:
{ "type": "start", "v": 1,
"call": { "external_call_id": "3f2a9c", "direction": "inbound", "started_at": "2026-09-22T10:00:00Z" },
"audio": { "encoding": "mulaw", "sample_rate": 8000,
"channels": [ { "index": 0, "role": "customer" }, { "index": 1, "role": "agent" } ] } }| Field | Rule |
|---|---|
v | The number 1 |
call.external_call_id | Required. 1–128 printable ASCII characters. Unique per call, for all time: it is how a reconnect finds the call again, and a used id can never start a new call. |
call.direction | Optional: inbound, outbound or unknown (the default) |
call.started_at | Optional, RFC 3339. Defaults to when the gateway received start. |
call.customer | Optional. Who is calling, when your phone system knows. See Say who is calling. |
audio.encoding | pcm16 (signed 16-bit little-endian), mulaw or alaw |
audio.sample_rate | 8000 or 16000, for every encoding |
audio.channels | One or two channels, indexes 0..n-1, each role at most once. Roles: customer, agent, or mixed (both parties on one channel, only on its own). |
Give each party their own channel whenever your phone system can. On a Live Connect stream each declared channel is transcribed on its own, so a line heard on the customer's channel is known to be the customer's. A mixed channel has to be guessed at, and a wrong guess makes a wrong suggestion. Each transcribed channel counts as one of your plan's live streams, so two channels usually count as two. The exception is a source your admin set to transcribe the customer only (in Settings → Live sources): its agent channel is neither transcribed nor counted.
This is about live streams only. A recording sent to POST /calls follows its own rules — see
channel_layout in Submitting a call.
start never names an agent. People join through events.
The gateway answers:
{ "type": "started", "v": 1, "session": "k3Jd9x", "resumed": false }session is for your logs and for Callens support. You may send audio straight after start,
before started arrives: the gateway holds up to 2 seconds per channel and drops the oldest.
Say who is calling
This part is optional. If your phone system knows who the call is with, say so in call.customer.
It takes the same identifiers as the customer events API, and at
least one of them:
"call": { "external_call_id": "3f2a9c", "direction": "inbound",
"customer": { "phone": "+201001234567",
"email": "karim@example.com",
"external": [ { "namespace": "crm", "id": "A-1001" } ] } }| Field | Rule |
|---|---|
customer.phone | At most 32 characters: digits, letters, spaces and + - ( ) . # * , ; : /. Send it as your system has it. |
customer.email | At most 254 printable ASCII characters, with an @. |
customer.external | Up to five { "namespace", "id" } pairs: your own system's id for this person. namespace is a lower-case slug of at most 32 characters, such as crm. id is at most 128 printable ASCII characters. |
What Callens does with it:
- It looks for a customer it already knows, once. A live call never creates a customer and never changes one. The identifiers must name exactly one customer: if they name nobody, or two different customers, the call has no customer.
- A number must be one Callens can read as a phone number. A placeholder for a withheld caller
(
anonymous,Unknown), an extension or a short code matches nobody. Send the international form when you have it. - A blank value is read as not sent.
"email": ""beside a number is just the number. - It does not keep what you sent. The identifiers are not stored with the live call and are not logged. The call keeps only a link to the customer it found.
- For an organization that has customer events turned on, the line Callens suggests to the agent can take that customer's earlier calls, messages and notes into account, and the agent is told when it did. This happens only when every agent on the call is allowed to see that customer in Callens. For every other organization the block is ignored.
- You are not told whether the caller was recognised. The answer to
startis the same either way. - It is read when the call first starts. On a reconnect it is ignored.
- The call comes first. A block that is not valid in every part is ignored whole — never half
used — and answered with a non-fatal
bad_customererror afterstarted. The call goes on exactly as if you had sent none. An address is checked as an address: two of them in one field are not one.bad_customeris about the form of what you sent, never about whether anybody was recognised.
A caller's number is only as trustworthy as the caller id your phone system was given. Callens treats the match as unconfirmed and tells the agent so.
A recognised customer's live call belongs to that customer. If the customer is erased, or their data expires, the live call and its transcript are deleted with them, even while the call is in progress.
Send audio
Each binary frame is one channel byte followed by the payload in the declared encoding:
[ channel index: 1 byte ][ payload … ]- 20–100 ms of audio per frame is best. Send at real-time pace.
- A PCM16 payload must be an even number of bytes.
- A frame for an undeclared channel, or an odd-length PCM16 payload, is a fatal
bad_frame. - Audio sent while the call is on hold is discarded, and nothing is transcribed or billed while it is held.
Lifecycle events
Every event is idempotent and takes an optional "at" (RFC 3339).
| Message | Meaning |
|---|---|
{"type":"agent_joined","external_agent_id":"1234"} | A person is on the call. A conference is several joins. |
{"type":"agent_left","external_agent_id":"1234"} | That person left. |
{"type":"transferred","from":"1234","to":"5678"} | One leaves and another joins, at once. from may be null. |
{"type":"hold"} / {"type":"resume"} | On hold, and off hold. |
{"type":"stop","reason":"completed"} | The call ended. The gateway answers ended and closes with 1000. |
external_agent_id is your name for the person: an extension, an agent id, a SIP user part.
Agents map it to themselves in Callens by claiming that seat on the Live page. You do not need to
know who they are.
No personal data in ids. external_call_id and external_agent_id are stored and logged. Never
put a phone number, a customer's name or an email address in either. The one place a caller's number
or address belongs is call.customer, which is neither.
An event with a missing or invalid field is answered with a non-fatal bad_event error and is not
applied; the call goes on. stop is never refused: a missing reason becomes completed, and one over
64 characters is cut. Events sent before started arrives are applied in order once the call is open
(at most 64).
What the gateway sends you
Three message types. Ignore any type you do not recognise, and any field you do not recognise — that is how version 1 grows without breaking you.
| Message | When |
|---|---|
started | The call was accepted (resumed: false), or taken up again after a reconnect (resumed: true). |
error | { "type": "error", "code": "…", "message": "…", "fatal": true }. A fatal error is always followed by a close. |
ended | { "type": "ended", "reason": "…" }. The call ended; a 1000 close follows. |
You are never sent transcript text. The agents on the call see it in Callens.
Errors
code | Fatal | Close | What to do |
|---|---|---|---|
unauthorized | yes | 1008 | The credential is wrong. Do not retry with it. |
source_revoked | yes | 1008 | An admin revoked this source. Get a new credential. |
bad_start | yes | 1008 | start is missing, late (10 s) or invalid. Fix the message. |
unsupported_audio | yes | 1008 | The encoding, rate or channel layout is not one listed above. |
bad_frame | yes | 1008 | An undeclared channel, or a malformed payload. |
frame_too_large | yes | 1009 | Over 16 KB of text or 64 KB of audio in one frame. |
not_in_plan | yes | 1008 | Your organization's plan does not include Live Assist. |
stream_limit | yes | 1008 | Not enough free live streams for this call's channels. |
budget_exhausted | yes | 1008 | Your organization's AI budget refuses new calls. |
capacity | yes | 1013 | The gateway cannot take the call now. Retry later. |
call_ended | yes | 1008 | This external_call_id has ended. It can arrive during a call when Callens has ended it. Ids are never reused: do not start it again. |
channels_mismatch | yes | 1008 | A reconnect declared different audio from the original start. |
superseded | yes | 1008 | A newer connection took this call over. Stop sending on this one. |
idle | yes | 1000 after ended | No audio for 5 minutes while not on hold. |
max_duration | yes | 1000 after ended | The call reached 3 hours. |
unknown_type | no | — | You sent a type the gateway does not know. |
bad_event | no | — | An event had a missing or invalid field; it was not applied. |
bad_customer | no | — | call.customer was not valid and was ignored. The call goes on with no customer. |
transcription_degraded | no | — | Transcription for channel is retrying. Keep streaming. |
transcription_unavailable | no | — | Transcription for channel has stopped. The call goes on. |
Reconnecting
A socket that closes without stop is a disconnect, not the end of the call. You have 30
seconds to open a new socket and send the same start: the same source, the same
external_call_id and the same audio block. You get started with resumed: true, and the call
carries on.
- Do not replay audio across a reconnect. What was lost in the gap stays lost.
- If the old socket is somehow still open, the new one wins and the old one gets
superseded. - After 30 seconds without a reconnect the call ends, and a later
startgetscall_ended. - A call on hold when the socket dropped is still on hold after the reconnect. Send
resumewhen it is.
Rotating the credential
Rotating (Settings → Live sources → Rotate) stops the old credential at once. A call already streaming on it loses transcription until your system reconnects with the new credential; that reconnect resumes the same call. Rotate between calls, and deploy the new credential before the next one starts.
A complete call
→ (upgrade, Authorization: Bearer clkl_{tenant_id}_{secret})
→ {"type":"start","v":1,"call":{"external_call_id":"pbx-000123"},"audio":{"encoding":"mulaw","sample_rate":8000,"channels":[{"index":0,"role":"customer"},{"index":1,"role":"agent"}]}}
→ [0x00][160 bytes] [0x01][160 bytes] … every 20 ms
← {"type":"started","v":1,"session":"k3Jd9x","resumed":false}
→ {"type":"agent_joined","external_agent_id":"1234"}
→ {"type":"hold"} … {"type":"resume"}
→ {"type":"transferred","from":"1234","to":"5678"}
→ {"type":"stop","reason":"completed"}
← {"type":"ended","reason":"completed"}
← (close 1000)