Tone Media Streams
The WebSocket protocol Tone speaks to your own voice stack.
The tier-2 integration contract: you buy a +91 number from Tone, Tone handles TRAI compliance and the carrier, and every call's audio is handed to YOUR server over a WebSocket. You run STT, LLM and TTS; Tone runs everything else. Protocol first, then setup, then the local test loop.
What you get
- Every call on a BYO number (outbound via
POST /v1/calls, inbound from the PSTN) opens one WebSocket from Tone to your endpoint, carrying the caller's audio and accepting yours. - The protocol is Twilio Media Streams-shaped — same seven events, same
camelCase fields, same base64 payloads — so code and framework serializers
written for Twilio (Pipecat's
TwilioFrameSerializerin particular) work with at most a config change. Pipecat also ships anExotelFrameSerializer; prefer the Twilio one against Tone. - The carrier behind the number is Tone's concern, not yours. Tone may change carriers; this protocol will not change under you.
Wire protocol
All frames are JSON text messages. Every frame Tone sends carries a
sequenceNumber (string, monotonic). Audio is base64 in media.payload, no
headers, no containers.
Tone → you
{"event":"connected","protocol":"Call","version":"1.0.0","sequenceNumber":"1"}
{"event":"start","sequenceNumber":"2","streamSid":"MZ…",
"start":{"streamSid":"MZ…","callSid":"<Tone call id (uuid)>","tracks":["inbound"],
"mediaFormat":{"encoding":"audio/l16","sampleRate":8000,"channels":1},
"customParameters":{"callId":"<uuid>","direction":"outbound","from":"+91…","to":"+91…", "…":"…"}}}
{"event":"media","sequenceNumber":"3","streamSid":"MZ…",
"media":{"track":"inbound","chunk":"1","timestamp":"20","payload":"<base64>"}}
{"event":"dtmf","streamSid":"MZ…","dtmf":{"track":"inbound_track","digit":"5"}}
{"event":"mark","streamSid":"MZ…","mark":{"name":"turn-3"}} // playback reached your mark
{"event":"stop","streamSid":"MZ…","stop":{"callSid":"<uuid>","reason":"completed"}}customParameters is three layers, later wins: static pairs you configured on
the number, the variables map passed to POST /v1/calls (so your dialer can
hand your bot per-call context — order id, customer name), then the identity
facts callId, direction, from, to. All values are strings.
mediaFormat.encoding is audio/l16 (16-bit signed little-endian PCM, mono)
or audio/x-mulaw (G.711 μ-law, always 8000 Hz) — whichever you configured.
You → Tone
{"event":"media","streamSid":"MZ…","media":{"payload":"<base64 audio in the same format>"}}
{"event":"mark","streamSid":"MZ…","mark":{"name":"turn-3"}} // echoed back when played
{"event":"clear","streamSid":"MZ…"} // barge-in: drop audio Tone has queued but not yet playedThree rules that decide whether your bot feels right on a phone:
- Send audio in small chunks (20–100 ms).
clearcannot recall audio the carrier is already playing; what you have queued ahead is what a barge-in costs you. - Use
mark. It is the only reliable "the caller has now heard it" signal — TTS lands far faster than it plays, and speaking again at "TTS finished" talks over yourself. - Hang up by closing the socket. Tone closes the carrier leg, bills the call, and sends your webhook.
Audio
| Setting | Values | Notes |
|---|---|---|
format | linear16 (default) | mulaw8k | μ-law is the Twilio-compat mode |
sampleRate | 8000 (default) | 16000 | linear16 only; 16k is better for ASR |
Tone resamples between the carrier's rate and yours.
Setting up a number
- Declare your sender classification once, in the dashboard under
Compliance → profile. It is the
purposethe pre-dial gate uses for every BYO dial — there is no agent to read one from. (PATCH /v1/compliance/profilebacks that screen, but it takes a dashboard session, not an API key — a key gets403.) - Put the credential your endpoint expects into Tone's secret store, in the
dashboard under Secrets — the same store tool credentials use. Tone sends
it as the
Authorizationheader value, verbatim. (POST /v1/secretsis session-only too: minting credentials is deliberately closed to API keys.) - Switch the number:
PATCH /v1/numbers/{id}
{
"routingMode": "byo_ws",
"mediaEndpoint": {
"type": "static",
"url": "wss://bot.example.com/media",
"format": "linear16",
"sampleRate": 8000,
"authSecretId": "<secret id>",
"customParameters": { "team": "support" }
}
}type: "webhook" instead POSTs {callId, from, to, direction, customParameters} to an https:// URL at call time and expects
{"url":"wss://…"} back — per-call routing for servers that cannot carry
session state in a static URL. Static URLs get ?callId= appended.
Endpoints must be public wss:// (or https:// for the webhook). Hosts that
resolve to private address space are refused, both when you save the config
and again when Tone connects.
Placing a call
POST /v1/calls
{ "numberId": "<byo number>", "toE164": "+91…", "variables": { "order_id": "A-1042" } }No agentId — the number's mode forbids one. The compliance gate runs exactly
as it does for a Tone-agent call (calling window, your DNC list, consent), and
a blocked dial is a 403 with the audit rows already written.
SIP trunking (for hosted platforms)
If your voice AI is a hosted platform — Retell, Vapi, ElevenLabs Agents, LiveKit, OpenAI Realtime — it consumes a SIP trunk, not a WebSocket. Tone provisions one per number on its SIP-capable carrier:
POST /v1/numbers/{id}/sip
{ "sipUri": "sip:abc123@sip.retellai.com;transport=tls", "transport": "tls" }The response carries, once, the termination credentials your platform uses to dial out through Tone:
{ "termination": { "domain": "…sip.vobiz.ai", "username": "tone_…", "password": "…", "realm": "…" } }- Inbound: calls to the number are sent to
sipUri(your platform's ingress). Tone adds the platform-standard headers; your platform sees the caller asFrom. - Outbound: your platform dials the termination domain with digest auth and the Tone number as caller id.
DELETE /v1/numbers/{id}/siptears the trunk pair down (and is how you rotate credentials).- SIP trunking is available on numbers bought on Tone's SIP-capable carrier; the purchase flow tells you which.
Compliance on SIP calls is post-hoc, not pre-dial. Your platform dials the
carrier directly, so Tone cannot stand in front of the call the way it does
for WebSocket numbers. Pre-flight recipients with POST /v1/compliance/check
(or the batch form) before dialling; Tone records every SIP call from the
carrier's CDR — priced at the same ₹2.50/min, with MOS/jitter/packet-loss in
the call record — and fires call.completed. The evidence pack for a number
includes those calls.
Billing
A BYO minute is ₹2.50 — telephony plus platform (gate, CDR, recording, webhooks, the bridge); the agent component is yours to run. Plus the number's monthly rental. A call that never connects, or that Tone refused, bills nothing.
Webhooks
Subscribe to call.completed. You get it for every finished call — answered,
no answer, busy, failed — with the disposition in the payload, so your dialer
can react to the ones that did not connect.
Testing without a phone
Point the fake caller at your server:
cd voice && pnpm test:byo -- wss://bot.example.com/media --wav hello.wav --format linear16 --rate 8000It performs the handshake, streams the WAV at real time as a caller would,
prints every frame you send, echoes your marks, and writes what a caller
would have heard to byo-reply.wav. With no --wav it streams silence —
enough to prove the handshake and your mark/clear plumbing.
For a full carrier loop locally, run the fake carrier (skrull/) with
BYO_ALLOW_PRIVATE_TARGETS=true on the voice service and a BYO number on a
tone_test_ key: the dial, the bridge, billing and the webhook all execute
against your endpoint on localhost with no money and no phone ringing.
Refusals you may see in the call log
endReason | Meaning |
|---|---|
refused_no_endpoint | Number is BYO but has no media endpoint configured |
refused_endpoint_unreachable | Connect (or webhook fetch) failed or timed out |
refused_endpoint_forbidden | Endpoint resolved to a private address or was not wss:// |
refused_media_format | Carrier rate and your configured rate cannot be bridged |
max_duration | The call hit the platform ceiling (60 min) |
Each is a failed disposition, reported — never a silent drop.