Tone docs

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.50 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 403, 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-onlyDeveloper → Secrets → New secret. The store is write-only by design: there is no read-back path for a credential, and an API key cannot create one. Copy the returned secret id; that is what mediaEndpoint.authSecretId references below.

Keep the returned id. There is no read-back endpoint — this is the only moment the plaintext crosses the API. Passing a credential inline in mediaEndpoint instead is refused outright, since that field rides ordinary dashboard reads.

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" }
    }
  }'

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.

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.

When something doesn't connect, the call log's endReason says why rather than dropping silently:

endReasonWhat it means
refused_no_endpointThe number is BYO but has no media endpoint configured
refused_endpoint_unreachableTone couldn't connect, or your webhook fetch timed out
refused_endpoint_forbiddenYour endpoint resolved to a private address, or wasn't wss://
refused_media_formatThe carrier's sample rate and your configured rate can't be bridged
max_durationThe call hit the 60-minute platform ceiling

Every one of these is reported as a failed disposition with a reason. Tone never abandons a call row silently.

Testing before you touch a phone

Point the fake caller at your server — it does the handshake, streams a WAV at real time the way a caller would, prints every frame you send back, echoes your marks, and writes what a caller would have heard to a WAV file:

cd voice && pnpm test:byo -- wss://bot.example.com/media --wav hello.wav --format linear16 --rate 8000

With a tone_test_ key and a sandbox number, the whole path — dial, gate, bridge, billing, webhook — runs against your endpoint with no money and no phone ringing.

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.50 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