WebSocket flow — end-to-end
Complete recipe — create a websocket call, stream raw PCM, handle agent audio + JSON events.
Use this when you control both ends of the audio — server-side bots, SIP bridges, audio file replay, custom voice surfaces. Two-step flow: create the call, then open the WS.
Know the audio format
The wire format is fixed: send 16 kHz PCM-16 mono, receive 24 kHz PCM-16 mono. If you're bridging from a narrowband source (e.g. a SIP trunk), resample to 16 kHz before sending and downsample the 24 kHz output on your side.
Create the call
RESP=$(curl -s -X POST https://dashboard.zoxa.ai/api/v1/call \
-H "X-API-Key: zsk_..." \
-H "Content-Type: application/json" \
-d '{
"transport": "websocket",
"agentId": "<agent.uuid>"
}')
CALL_ID=$(echo "$RESP" | jq -r .callId)
echo "Call: $CALL_ID"Response:
{
"callId": "call_abc123",
"audioUrl": "/api/v1/ws/audio/call_abc123",
"transport": "websocket",
"status": "pending"
}Open the WebSocket
wss://dashboard.zoxa.ai/api/v1/ws/audio/{callId}?api_key=zsk_...The moment you connect, the call's status flips from pending to active and the agent starts.
import WebSocket from "ws";
import { Readable } from "node:stream";
const ws = new WebSocket(
`wss://dashboard.zoxa.ai/api/v1/ws/audio/${callId}?api_key=zsk_...`,
);
ws.on("open", () => {
// Send PCM frames in small chunks — 20 ms of int16/16kHz mono = 640 bytes
micStream.on("data", (chunk) => ws.send(chunk));
});
ws.on("message", (data, isBinary) => {
if (isBinary) {
// Agent audio (24 kHz PCM) — play through your speaker/RTP sink
speakerSink.write(data);
} else {
const event = JSON.parse(data.toString());
switch (event.type) {
case "rtf-user-transcription":
if (event.payload.final) console.log("USER:", event.payload.text);
break;
case "rtf-bot-text":
console.log("BOT:", event.payload.text);
break;
case "error":
console.error("Call error:", event.message);
break;
}
}
});
ws.on("close", (code, reason) => {
console.log("Call ended", code, reason.toString());
});import asyncio, json, websockets
async def run(call_id: str):
url = f"wss://dashboard.zoxa.ai/api/v1/ws/audio/{call_id}?api_key=zsk_..."
async with websockets.connect(url) as ws:
async def send_mic():
async for chunk in mic_pcm_20ms_frames(): # 640 B int16 LE 16 kHz
await ws.send(chunk)
async def recv_loop():
async for msg in ws:
if isinstance(msg, bytes):
speaker.write(msg) # agent audio
else:
event = json.loads(msg)
if event["type"] == "rtf-user-transcription":
if event["payload"]["final"]:
print("USER:", event["payload"]["text"])
elif event["type"] == "rtf-bot-text":
print("BOT:", event["payload"]["text"])
elif event["type"] == "error":
print("call error:", event["message"])
await asyncio.gather(send_mic(), recv_loop())
asyncio.run(run("call_abc123"))Close cleanly when done
Close the WS with code 1000 to signal a clean end. The server's finally block marks the call completed and finalizes transcript/recording.
ws.close(1000, "done");If you crash or the WS times out, the call still gets a history row: an abrupt drop after the pipeline started lands as endedReason="user_hangup" (the client disconnected), while a drop before the pipeline ever started lands as connectionStatus="cancelled" with endedReason: null.
Wire-protocol cheatsheet
| Direction | Type | Content |
|---|---|---|
| Client → Server | Binary | Raw PCM-16 at 16 kHz. ~20 ms frames. |
| Server → Client | Binary | Raw PCM-16 agent audio at 24 kHz. Play out as-is. |
| Server → Client | Text (JSON) | {type, payload} — see protocol. |
When the call ends
Same as every other transport — GET /calls/{callId} returns transcript, recording, cost, per-turn timings. Or set webhook.url on the agent and the call.ended event lands automatically.
Common pitfalls
| Symptom | Likely cause |
|---|---|
| Agent never starts speaking | You haven't started sending audio yet — the agent is waiting greeting.userTimeoutS for the user. Set greeting.speakFirst: "agent" if the bot should open. |
| Choppy audio in either direction | Frames too big. Send 20 ms at a time, don't batch. |
1008 Call transport mismatch on connect | The callId was created with transport=webrtc. Use WebRTC instead. |
1008 Call not available on connect | The call already went active from another WS, or it already terminated. Each call gets one WS — create a new call. |
Related
- WebSocket transport — endpoint reference
- WebSocket protocol — full wire spec