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 (not on SIP gateway calls)
{"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. Marks are echoed on Exotel and Plivo calls. On a call that arrives through Tone's SIP gateway (a customer SIP trunk, or a gateway-routed carrier DID), marks are currently not echoed. - Hang up by closing the socket. Tone closes the carrier leg, bills the call, and sends your webhook.
Handing the call to a person
Your bot can transfer the caller to one of your transfer destinations — a person on a phone, verified, inside their hours. Tone rings them, plays them a short brief, and connects the caller; your bot says its own lines, because Tone speaks nothing on your call.
// you → Tone: after your bot has said its "connecting you now" line
{"event":"transfer","streamSid":"MZ…",
"transfer":{"destinationId":"<uuid>","reason":"complaint","note":"Parcel arrived broken","handoff":"cold"}}
// Tone → you
{"event":"transfer","streamSid":"MZ…","transfer":{"status":"started","transferId":"<uuid>","destination":"Delivery team","warm":false}}
{"event":"transfer","streamSid":"MZ…","transfer":{"status":"handover","transferId":"<uuid>"}}
{"event":"stop","streamSid":"MZ…","stop":{"callSid":"<uuid>","reason":"transferred"}}destinationIdis one ofGET /v1/transfer-destinations.reasonis one of the transfer reasons (anything else isother); the person hears it in the brief.noteis one line for the person, at most 160 characters.- Cold (the default) hands the caller over at once. Tone waits for the audio you have already
sent to finish playing, sends
handover, and closes your socket withreason: "transferred". - Warm keeps the caller with your bot while the person is rung, hears the brief and presses 1.
You get
started, thenansweredwhen they pick up, thenacceptedwhen they press 1 — say your handover line then; Tone lets go 3 seconds later, or as soon as you send{"event":"transfer","transfer":{"action":"handover"}}. If they do not take it you getmissedwith anoutcome, and the caller is still yours to talk to. {"event":"transfer","transfer":{"action":"cancel"}}calls it off while the person is being reached (the caller changed their mind). After they press 1 it is too late.- A transfer Tone cannot start comes back as
{"status":"refused","refusal":"…"}— the same refusals as the API — and the call carries on. - Your backend can start the same transfer with
POST /v1/calls/{id}/transfer, adding"handoff":"warm"for warm; your bot is sent the same frames, with"trigger":"api".
Transfers run on Plivo numbers (cold or warm) and on your own SIP trunk (always warm). They are not available on Exotel numbers: an Exotel BYO call is a stream with no step to hand it over. The person's minutes bill at your call's rate: ₹2.00 a minute, or ₹1.50 on your own SIP trunk.
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 gets401 unauthenticated: "API keys are not accepted here".) - Put the credential your endpoint expects into Tone's secret store, in the
dashboard under Settings → Secrets — the same store tool credentials use.
Tone sends it as the
Authorizationheader value, verbatim. Copy ID on the secret's row gives you theauthSecretIdbelow. (/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.
routingMode: "byo_ws" must be sent with mediaEndpoint in the same request
unless the number already has one stored — otherwise 422. Clearing the
endpoint of a byo_ws number is also 422. routingMode: "byo_sip" cannot be
set or cleared by PATCH (409); use POST/DELETE /v1/numbers/{id}/sip.
Keep credentials out of url: a user:password@ in it is refused (422), but
a token in its query string is not detected — it is stored and returned on
every read. Use authSecretId.
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.
An authSecretId that is not one of your organization's secrets is a 422, and
deleting a secret a number still uses is a 409 naming the number. Tone never
opens your endpoint without the credential you configured, so a missing one would
refuse every call on the number.
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.
Opt-outs are yours to record. Tone forwards a DTMF digit to your bot as a
dtmf frame, but it does not itself record an opt-out on a BYO call: there is
no DTMF-9 suppression and no verbal opt-out detection, because Tone has no
transcript of your conversation.
When a caller opts out, record it with POST /v1/compliance/call-outcomes
(outcome: "opt_out" or "complaint") or POST /v1/consent/opt-out.
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 depends on where your platform sends its INVITE, and there are two arrangements.
Direct to the carrier — the default today. Your platform dials the carrier
itself, 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.00/min, with MOS/jitter/packet-loss in the call record —
and fires call.completed. Those calls appear as call records but write no
compliance-check rows.
Through Tone's SIP gateway — point your platform at sip.usetone.ai instead
and outbound calls are gated before they are dialled, exactly as
POST /v1/calls is: all six checks, the same compliance_checks rows, the same
sealed evidence, and a 403 on the INVITE if a call is refused (with
compliance.check.blocked on your webhook). Your platform must present one of
your own Tone numbers as the caller ID, and the trunk must have origination
enabled — ask support, it is off by default. You keep the pre-flight API either
way; this removes the window between checking and dialling.
Billing
A BYO minute is ₹2.00 — 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. Tone runs no extraction on a BYO
call, so summary and outputs are always null, and call.completed is
delivered at once rather than after a wait for a summary.
Testing without a phone
Call your endpoint from the dashboard: Numbers, the number's Settings,
Start test call. Your microphone is the caller and your bot answers,
through the same bridge a phone call uses. Your bot gets the upgrade with your
Authorization secret and ?callId=, a start frame with the number's custom
parameters, and media at 8 kHz, a phone line's rate, in the format you
configured. Each mark is echoed once the browser has played the audio before
it; a mark whose audio your clear dropped is never echoed.
The test call is free and dials nothing. It counts toward your daily test-call
allowance, ends after 5 minutes, and tests the endpoint as saved. It shows up in
the call log with its recording, sends your webhooks like any call, and a
refusal carries the same endReason a phone call would. transfer frames are
refused on a test call.
Test mode reaches your endpoint too. Dial a sandbox number with a
tone_test_ key and no agentId: when it answers, Tone opens your endpoint
as on a live call and a recorded caller takes it. The caller listens while
your bot greets, says one recorded line, waits for your bot to answer, then
hangs up, all within 60 seconds. The frames are a live call's: ?callId= set
to the id POST /v1/calls returned, start with your custom parameters, the
call's variables, direction: "outbound", from and to, and media at
8 kHz, with marks echoed when the audio before them would have finished
playing. The call ends answered with a recording, or failed with a
refused_* reason if Tone can't reach you. It is free, and transfer frames
are refused. Busy, no-answer and voicemail sandbox numbers end without opening
your endpoint.
Refusals you may see in the call log
The dashboard's call detail shows the endReason as "Ended because …".
endReason | Meaning |
|---|---|
refused_no_endpoint | Number is BYO but has no media endpoint configured, or its authSecretId can no longer be decrypted (the secret is gone) |
refused_endpoint_unreachable | Connect failed or timed out — including a 401/403 or any other failed upgrade from your endpoint — or a webhook endpoint timed out or answered non-2xx |
refused_endpoint_forbidden | Endpoint's address resolved into private or forbidden address space |
refused_media_format | Carrier rate and your configured rate cannot be bridged |
refused_capacity | Tone's BYO capacity was full |
refused_internal_error | Something failed on Tone's side |
caller_hung_up_during_resolve | The caller hung up while Tone was still setting the call up; your endpoint was never opened |
error | The connection to your endpoint errored during the call; always failed on a BYO call |
max_duration | The call hit the platform ceiling (60 min). It ends answered and is billed |
Every refused_* closes the call failed, bills nothing, and fires
call.completed and call.failed — reported, never a silent drop.