Realtime Protocol
The call socket behind the SDK, for clients in any language
The Web SDK handles all of this for you. This page is for building a client without it: a native mobile app, a Python or Go service, a hardware device. A call is a single-use ticket, then one WebSocket that carries audio as binary frames and everything else as small JSON messages.
1. Get a ticket
POST https://api.voice213.com/api/agents/public/{publicId}/call{ "token": "eyJhbGciOi…", "wsUrl": "wss://api.voice213.com/ws/agent" }The ticket opens one call, within two minutes. A ticket requested from a web page (with an Origin header) only opens a socket from that same origin. See Start a Call.
2. Connect
wss://api.voice213.com/ws/agent?token=<token>Open the wsUrl you were given, with the token as the token query parameter. A used, expired or invalid ticket is refused during the handshake with HTTP 401.
The server then starts the call: it checks the account's minutes and lines, and connects the voice model. This takes one to a few seconds. When the agent is on the line, it sends ready:
{
"type": "ready",
"conversationId": "6ac2aab5f3fdade650f3b120",
"maxSeconds": 600,
"agent": { "name": "Lamsa", "avatar": { "from": "#FF9A62", "to": "#F0436E", "url": "https://pub-0123456789abcdef.r2.dev/agents/6a2f/avatar/7.webp" }, "language": "ar-DZ" }
}If the call can't start, the server sends an error message instead and closes the socket with code 4000.
3. Talk
Audio
| Direction | Frames | Format |
|---|---|---|
| You → server | Binary | 16-bit signed little-endian PCM, mono, 16,000 Hz |
| Server → you | Binary | 16-bit signed little-endian PCM, mono, 24,000 Hz |
- Send the caller's audio in 20–40 ms frames (640–1,280 bytes) at real-time pace, from
readyuntil the end. Keep sending during silence: the end of a sentence is detected from the silence after it. - The agent's voice arrives in chunks of varying length, usually faster than real time. Queue it and play it in order.
- If your platform can't send binary frames, send
{ "type": "audio", "data": "<base64 PCM>" }instead. - A message can't exceed 512 KB.
Messages from you
| Message | Effect |
|---|---|
{ "type": "text", "text": "…" } | A typed message, answered like speech. It is recorded as the caller's turn and echoed back as a final transcript. Stop playing the agent's current sentence when you send it. |
{ "type": "end" } | Hang up politely. The server closes the conversation, answers ended, and closes the socket. |
{ "type": "ping" } | Answered with { "type": "pong" }, if you want an application-level heartbeat |
Messages from the server
| Message | Meaning |
|---|---|
{ "type": "ready", "conversationId", "maxSeconds", "agent" } | The call is live |
{ "type": "transcript", "role": "user" | "agent", "text", "final" } | Words as they're spoken. Each message carries the whole turn so far: replace, don't append. final: true closes the turn. |
{ "type": "interrupted" } | The caller spoke over the agent: stop playback now and drop everything queued |
{ "type": "tool", "name", "status": "running" | "done", "ok"? } | The agent is running one of its tools, or has finished |
{ "type": "ended", "reason", "durationSeconds", "billedSeconds", "freeSeconds", "conversationId" } | The call is over. See end reasons. |
{ "type": "error", "code", "error" } | The call couldn't start, or failed |
{ "type": "pong" } | The answer to your ping |
4. End
Send { "type": "end" } and wait for ended (the server then closes with code 1000). Closing the socket without it also ends the call, with the reason caller_hangup. The agent can end the call too, after its goodbye: you get ended with agent_ended.
The server pings every connection every 15 seconds at the WebSocket level, and drops connections that stop answering. WebSocket libraries answer these pings for you.
Close codes and errors
| Close code | Meaning |
|---|---|
1000 | The call ended normally |
4000 | The call couldn't start. The error message before it says why. |
4404 | The agent no longer exists |
1011 | The server hit an error while setting up the call. Retry. |
1006 | The connection dropped without a close (network) |
error code | Meaning |
|---|---|
QUOTA | The account has no call minutes left. A call needs at least 30 seconds left to start. |
BUSY | The account already has 5 calls running, or every line is busy. Retry in a minute. |
MODEL | No voice model could be reached. Retry. |
OWNER | The agent's account no longer exists |
NOT_FOUND | The agent no longer exists |
Example: Python
A complete client in Python: it plays question.wav (16 kHz, 16-bit, mono) to the agent, prints the conversation, and saves the agent's voice to answer.wav.
# pip install websockets
import asyncio
import json
import urllib.request
import wave
import websockets
API = "https://api.voice213.com"
AGENT_ID = "k3x9m2p7q4wz"
def call_ticket():
request = urllib.request.Request(f"{API}/api/agents/public/{AGENT_ID}/call", method="POST")
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)
async def main():
with wave.open("question.wav", "rb") as wav: # 16 kHz, 16-bit, mono
question = wav.readframes(wav.getnframes())
ticket = call_ticket()
voice = bytearray()
agent_said = asyncio.Event()
ended = asyncio.Event()
async with websockets.connect(f"{ticket['wsUrl']}?token={ticket['token']}", max_size=None) as ws:
async def receive():
async for message in ws:
if isinstance(message, bytes):
voice.extend(message) # the agent's voice: 24 kHz, 16-bit, mono
continue
event = json.loads(message)
if event["type"] == "ready":
print("connected:", event["conversationId"])
elif event["type"] == "transcript" and event["final"]:
print(f"{event['role']}: {event['text']}")
if event["role"] == "agent":
agent_said.set()
elif event["type"] == "error":
print("refused:", event["code"], event["error"])
break
elif event["type"] == "ended":
print("ended:", event["reason"], f"after {event['durationSeconds']} s")
break
ended.set()
receiver = asyncio.create_task(receive())
await asyncio.wait_for(agent_said.wait(), 30) # its greeting (leave out if it waits for the caller)
agent_said.clear()
# The question in real time, 40 ms (1,280 bytes) at a time, then 2 s of silence to end the turn
audio = question + bytes(2 * 16000 * 2)
for at in range(0, len(audio), 1280):
await ws.send(audio[at : at + 1280])
await asyncio.sleep(0.04)
await asyncio.wait_for(agent_said.wait(), 30) # its answer
await asyncio.sleep(2)
await ws.send(json.dumps({"type": "end"}))
await asyncio.wait_for(ended.wait(), 10)
await receiver
with wave.open("answer.wav", "wb") as out:
out.setnchannels(1)
out.setsampwidth(2)
out.setframerate(24000)
out.writeframes(voice)
asyncio.run(main())The same rules apply as for any server-side call: the agent's Allowed websites list must be empty, and each IP address can start 20 calls an hour.
Was this page helpful?