Bring your own voice stack
Keep your own STT, LLM and TTS. Rent Tone's +91 numbers, carrier and TRAI compliance, and get every call's audio over a WebSocket.
You already have a voice agent — Pipecat, LiveKit, Retell, your own pipeline — and what you're missing is an Indian phone number you can legally dial from. That's this tier.
Tone owns the number, the carrier relationship, the pre-dial compliance gate, the CDR and the billing. Every call opens a WebSocket to your server carrying the caller's audio, and accepts yours back. What the agent says is entirely yours.
₹2.00 per minute — telephony plus platform — plus the number's monthly rental. You're not paying for an AI stack you aren't using. A call that never connects, or that Tone refused, bills nothing.
The wire protocol is Twilio Media Streams-shaped: the same seven events, the same camelCase
fields, the same base64 payloads. Code written against Twilio — including Pipecat's
TwilioFrameSerializer — works here with at most a config change.
Read Tone Media Streams for the full protocol: every frame, the audio formats, SIP trunking for hosted platforms, and the local test harness. This page is the API path to a working call.
1. Declare your sender classification
There's no Tone agent on a BYO number, so there's nothing for the compliance gate to read a call purpose from. Declare it once for the organization:
This one is dashboard-only. Open Telephony → Compliance → Sender classification and set it there. An API key gets a
401, deliberately: the classification decides which calling window applies to every dial you place on your own infrastructure, so loosening it is a decision an accountable person makes rather than something a deploy script does. The change is recorded as aprofile_changeaudit row either way.
transactional, service, promotional, or collections — the Sender classification field
under Telephony → Compliance in the dashboard. This drives the calling-window rules
on every dial, so classify honestly — and note that dialling before you set it is a 422, by
design. Defaulting strict would silently window-block a support line; defaulting loose would
under-check a marketer.
2. Store your endpoint's credential
Your WebSocket server presumably wants an Authorization header. Put the value in Tone's secret
store rather than in the endpoint config — it's encrypted at rest and only decrypted on the signed
path that opens the call:
Also dashboard-only — Settings → Secrets → Add secret. The store is write-only by design: there is no read-back path for a credential, and an API key cannot create or list one. Each secret's row has a Copy ID button; that ID is what
mediaEndpoint.authSecretIdreferences below.
Saving the secret is the only moment the plaintext crosses the API. Don't put a credential in
mediaEndpoint.url instead, since that field rides ordinary dashboard reads: a user:password@ in
the URL is refused with a 422, but a token in the URL's query string is not detected — it is
stored and returned on every read. Use authSecretId. An
authSecretId that is not one of your organization's secrets is refused with a 422, and a secret
a number still uses cannot be deleted (409 naming the number) — either would leave every call on
that number refused, because Tone does not dial your endpoint without the credential you set.
Prefer the dashboard for step 3 as well? Expand the number on Numbers, switch it to Your voice stack (BYO), and pick the secret from the list — no ID to copy.
3. Point a number at your server
Get a number first — assign a free sandbox one
with a test key, or buy a real one with an admin key. Then switch its routing:
curl "$TONE_API/v1/numbers/$NUMBER" \
-X PATCH \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"routingMode": "byo_ws",
"mediaEndpoint": {
"type": "static",
"url": "wss://bot.example.com/media",
"format": "linear16",
"sampleRate": 8000,
"authSecretId": "SECRET_ID",
"customParameters": { "team": "support" }
}
}'Send routingMode: "byo_ws" with mediaEndpoint in the same request unless the number already has
one stored — otherwise it is a 422. Clearing the endpoint of a byo_ws number is a 422 too.
routingMode: "byo_sip" cannot be set or cleared by PATCH (409); use
POST/DELETE /v1/numbers/{id}/sip.
Tone appends ?callId=<id> to a static URL. If your server can't carry session state in a URL, use
"type": "webhook" with an https:// URL instead: Tone POSTs {callId, from, to, direction, customParameters} to it at call time and expects {"url":"wss://..."} back, so you can route each
call individually.
Your endpoint must be publicly reachable and wss:// (or https:// for the webhook form).
Hosts that resolve into private address space are refused twice — once when you save this config,
and again when Tone actually connects and re-resolves the DNS. That second check is the one that
matters: a hostname that's public today can point at 169.254.169.254 tomorrow.
4. Place a call
curl "$TONE_API/v1/calls" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'content-type: application/json' \
-d '{
"numberId": "'"$NUMBER"'",
"toE164": "+919876543210",
"variables": { "order_id": "A-1042" }
}'No agentId — the number's routing mode decides what runs the call, and passing one is a 422.
variables reach your server in the start frame's customParameters, so a per-call order id or
customer name arrives with the audio.
The compliance gate runs exactly as it does for a Tone agent: your DNC list, consent, calling
window. A blocked dial is a 403 with the audit rows already written — the fact that you're
running your own AI doesn't change your obligations under TCCCPR, which is rather the point of
renting the number from us.
Opt-outs during the call are yours to record: Tone forwards a DTMF digit to your bot but records no
opt-out on a BYO call. Report one with POST /v1/compliance/call-outcomes (outcome: "opt_out" or
"complaint") or POST /v1/consent/opt-out.
Inbound works with no extra setup: a call to this number opens the same WebSocket to your server.
Watching it work
Subscribe to call.completed for every finished call — answered, busy, no answer, failed — with
the disposition in the payload, so your dialer can react to the ones that didn't connect. See
Webhooks. Tone runs no extraction on a BYO call, so summary and outputs are always
null and call.completed is delivered at once.
When something doesn't connect, the call log's endReason says why rather than dropping silently
(the dashboard's call detail shows it as "Ended because …"):
endReason | What it means |
|---|---|
refused_no_endpoint | The number is BYO but has no media endpoint configured, or its authSecretId can no longer be decrypted (the secret is gone) |
refused_endpoint_unreachable | Tone couldn't connect: your endpoint failed the WebSocket upgrade (a 401/403 included), the connection timed out, or your webhook endpoint timed out or answered non-2xx |
refused_endpoint_forbidden | Your endpoint's address resolved into private or forbidden address space |
refused_media_format | The carrier's sample rate and your configured rate can't 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. On a BYO call this is always failed |
max_duration | The call hit the 60-minute platform ceiling. It ends answered and is billed |
Every refused_* closes the call failed, bills nothing, and fires call.completed and
call.failed. Tone never abandons a call row silently.
Testing before you touch a phone
Call your endpoint from the dashboard. Open Numbers, open the number's Settings, save its media endpoint, and press Start test call. Your microphone is the caller and your bot answers. Tone runs the same bridge a phone call uses, so your bot receives what a real call sends:
- the upgrade carries your
Authorizationsecret and?callId=; startcarries the number's custom parameters, pluscallId,direction: "inbound"andto;mediais your voice at 8 kHz, a phone line's rate, converted to the format you configured;- each
markyou send is echoed once the browser has played the audio before it. A mark whose audio yourcleardropped is never echoed.
The test call is free and nothing is dialled. It counts toward your daily test-call allowance, ends
after 5 minutes, and tests the endpoint as saved. It appears in the call log with its recording and
sends your webhooks like any call, and if Tone can't reach your bot it ends with the same
refused_* reason a phone call would. transfer frames are refused on a test call.
From your own code, with a test key
Dial a sandbox number with a tone_test_ key and no agentId, and when it answers Tone opens your
endpoint just as it does on a live call. A recorded caller takes the call:
- it listens while your bot greets (up to 5 seconds if your bot says nothing first);
- it says one line, in Hindi: "हाँ जी, मेरा ऑर्डर कब तक आएगा? मैं कल घर पर रहूँगा।" ("Yes, when will my order arrive? I'll be home tomorrow.");
- it waits for your bot to answer (up to 15 seconds), then hangs up.
Your bot sees the same frames a live call sends: ?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. Its marks are echoed when the audio before them would have finished
playing. The call ends answered with a recording and fires call.answered and call.completed.
If Tone can't reach your bot, the call ends failed with the same refused_* reason a live call
would. It is free, ends within 60 seconds, counts toward your concurrent BYO-call limit, and
transfer frames are refused.
Which sandbox numbers answer is in Test mode: busy, no answer and
voicemail end without opening your endpoint, and +91 55550 00006 still records the opt-out.
Two limitations to know up front
- Campaigns don't run on BYO numbers. Launching one is refused at the button rather than
failing per-dial, which would have produced thousands of mid-run errors instead of one clear
message. Drive your own dialing loop against
POST /v1/calls. - A negotiated per-minute rate doesn't apply here. BYO bills the fixed ₹2.00 telephony+platform component. A blended full-stack discount applied to a call whose agent component we never ran would charge you more than list, so it's deliberately not used.