zoxaAI
Homepage
API ReferenceExamples

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

DirectionTypeContent
Client → ServerBinaryRaw PCM-16 at 16 kHz. ~20 ms frames.
Server → ClientBinaryRaw PCM-16 agent audio at 24 kHz. Play out as-is.
Server → ClientText (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

SymptomLikely cause
Agent never starts speakingYou 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 directionFrames too big. Send 20 ms at a time, don't batch.
1008 Call transport mismatch on connectThe callId was created with transport=webrtc. Use WebRTC instead.
1008 Call not available on connectThe call already went active from another WS, or it already terminated. Each call gets one WS — create a new call.

On this page