Skip to content

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

DirectionFramesFormat
You → serverBinary16-bit signed little-endian PCM, mono, 16,000 Hz
Server → youBinary16-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 ready until 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

MessageEffect
{ "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

MessageMeaning
{ "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 codeMeaning
1000The call ended normally
4000The call couldn't start. The error message before it says why.
4404The agent no longer exists
1011The server hit an error while setting up the call. Retry.
1006The connection dropped without a close (network)
error codeMeaning
QUOTAThe account has no call minutes left. A call needs at least 30 seconds left to start.
BUSYThe account already has 5 calls running, or every line is busy. Retry in a minute.
MODELNo voice model could be reached. Retry.
OWNERThe agent's account no longer exists
NOT_FOUNDThe 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?