# Bring your own voice stack

Source: https://docs.usetone.ai/quickstart-byo

> 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](/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-only** — **Developer → 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](/test-mode#getting-a-sandbox-number-for-free)
with a test key, or buy a real one with an `admin` key. Then switch its routing:

```bash
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

```bash
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](/webhooks).

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

| `endReason` | What it means |
|---|---|
| `refused_no_endpoint` | The number is BYO but has no media endpoint configured |
| `refused_endpoint_unreachable` | Tone couldn't connect, or your webhook fetch timed out |
| `refused_endpoint_forbidden` | Your endpoint resolved to a private address, or wasn't `wss://` |
| `refused_media_format` | The carrier's sample rate and your configured rate can't be bridged |
| `max_duration` | The 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 `mark`s, and writes what a caller would
have heard to a WAV file:

```bash
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.
