Tonedocs

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 a profile_change audit 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.authSecretId references 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 …"):

endReasonWhat it means
refused_no_endpointThe number is BYO but has no media endpoint configured, or its authSecretId can no longer be decrypted (the secret is gone)
refused_endpoint_unreachableTone 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_forbiddenYour endpoint's address resolved into private or forbidden address space
refused_media_formatThe carrier's sample rate and your configured rate can't 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. On a BYO call this is always failed
max_durationThe 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 Authorization secret and ?callId=;
  • start carries the number's custom parameters, plus callId, direction: "inbound" and to;
  • media is your voice at 8 kHz, a phone line's rate, converted to the format you configured;
  • each mark you send 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 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:

  1. it listens while your bot greets (up to 5 seconds if your bot says nothing first);
  2. it says one line, in Hindi: "हाँ जी, मेरा ऑर्डर कब तक आएगा? मैं कल घर पर रहूँगा।" ("Yes, when will my order arrive? I'll be home tomorrow.");
  3. 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.
Was this page helpful?

On this page