Tonedocs

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 TwilioFrameSerializer in particular) work with at most a config change. Pipecat also ships an ExotelFrameSerializer; 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 played

Three rules that decide whether your bot feels right on a phone:

  1. Send audio in small chunks (20–100 ms). clear cannot recall audio the carrier is already playing; what you have queued ahead is what a barge-in costs you.
  2. 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.
  3. 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"}}
  • destinationId is one of GET /v1/transfer-destinations. reason is one of the transfer reasons (anything else is other); the person hears it in the brief. note is 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 with reason: "transferred".
  • Warm keeps the caller with your bot while the person is rung, hears the brief and presses 1. You get started, then answered when they pick up, then accepted when 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 get missed with an outcome, 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

SettingValuesNotes
formatlinear16 (default) | mulaw8kμ-law is the Twilio-compat mode
sampleRate8000 (default) | 16000linear16 only; 16k is better for ASR

Tone resamples between the carrier's rate and yours.

Setting up a number

  1. Declare your sender classification once, in the dashboard under Compliance → profile. It is the purpose the pre-dial gate uses for every BYO dial — there is no agent to read one from. (PATCH /v1/compliance/profile backs that screen, but it takes a dashboard session, not an API key — a key gets 401 unauthenticated: "API keys are not accepted here".)
  2. 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 Authorization header value, verbatim. Copy ID on the secret's row gives you the authSecretId below. (/v1/secrets is session-only too: minting credentials is deliberately closed to API keys.)
  3. 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 as From.
  • Outbound: your platform dials the termination domain with digest auth and the Tone number as caller id.
  • DELETE /v1/numbers/{id}/sip tears 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 …".

endReasonMeaning
refused_no_endpointNumber is BYO but has no media endpoint configured, or its authSecretId can no longer be decrypted (the secret is gone)
refused_endpoint_unreachableConnect 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_forbiddenEndpoint's address resolved into private or forbidden address space
refused_media_formatCarrier rate and your configured rate cannot be bridged
refused_capacityTone's BYO capacity was full
refused_internal_errorSomething failed on Tone's side
caller_hung_up_during_resolveThe caller hung up while Tone was still setting the call up; your endpoint was never opened
errorThe connection to your endpoint errored during the call; always failed on a BYO call
max_durationThe 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.

Was this page helpful?

On this page