# Tone — developer documentation
> The +91 phone-number API for AI agents.
This file contains every guide in full. The API reference is per-operation and
lives at https://docs.usetone.ai/llms.txt, or as OpenAPI 3.1 at https://docs.usetone.ai/openapi.json.
---
# Tone API
Source: https://docs.usetone.ai/
> The +91 phone-number API for AI agents — provision Indian numbers, run TRAI-compliant voice calls, and keep the evidence.
Tone gives an AI agent an Indian phone number.
It provisions **+91** numbers over an API, places and receives voice calls on
them in twelve Indian languages, and runs the TRAI compliance every one of those
calls is subject to — the calling windows, the Do-Not-Call checks, the consent
ledger and the audit trail that proves you did it.
It is a developer product, not a hosted chatbot builder. You bring the
conversation; Tone supplies the number, the carrier path, and the regulation
around the call.
## Three ways to use it
You do not pick one at signup. These are packaging, not account types — a single
organization can run all three at once, because the choice lives on each phone
number rather than on your account.
| | Telephony | Compliance | The voice agent | Rate |
|---|---|---|---|---|
| **Full stack** | Tone | Tone | **Tone** | ₹6.00/min + rental |
| **Bring your own voice** | Tone | Tone | **You** | ₹2.50/min + rental |
| **Compliance only** | **You** | Tone | **You** | per verdict |
- **[Full stack](/quickstart)** — you write a prompt, pick a voice, and Tone runs
the whole call. Start here if you want a working call in five minutes.
- **[Bring your own voice stack](/quickstart-byo)** — you already have an agent.
Tone hands you the audio over a WebSocket and stays out of the conversation.
- **[Compliance as an API](/quickstart-compliance)** — you already have a
carrier. Tone answers "may I legally dial this number right now?" and keeps
the evidence that you asked.
## Start here
## What you should know before you build
**`/v1` is a promise, not a version number.** Changes are additive only. New
endpoints, new optional request fields, new response fields and new enum members
can appear at any time and are not breaking. Nothing you receive today will be
removed, renamed or retyped. There is no plan for a `/v2`.
**Treat every enum as open.** Fields listing what exists today are telling you
what exists today, not what can ever arrive. Handle an unrecognised value rather
than throwing — that is the other half of the additive-only promise, and a
client that throws turns our compatible change into your outage.
**Branch on `error.code`, never on `error.message`.** Codes are contract;
messages are written for humans and get reworded. See
[Conventions](/conventions) for the envelope both share.
**Test mode is free and complete.** A `tone_test_` key works from the moment you
sign up — before KYC, before a DLT registration, before you have put any money
in. It runs the same compliance gate, writes the same call records and sends the
same webhooks with the same signatures. Going live is a key swap.
---
# Agent versioning
Source: https://docs.usetone.ai/agent-versioning
> Editing an agent changes nothing. Publishing does. Every call pins the version that ran it, so a finished call stays explainable.
Prompts change constantly, and a call placed last Tuesday was placed by a
different prompt from the one in the editor today. Versioning is what keeps
"why did it say that?" answerable.
## The rules
1. **Editing writes a working copy.** `GET /v1/agents/{id}` returns it. Nothing
about live traffic changes.
2. **Publishing freezes it** into an immutable, numbered version and points the
agent at it. This is the only step that changes behaviour.
3. **Every call pins its version at creation.** `calls.agentVersion` says which
one ran, forever — so you can bucket outcomes by prompt.
4. **Rolling back mints a NEW version** carrying the old content, rather than
moving a pointer backwards. The history stays append-only, and the rollback
itself is visible in it.
```bash
# publish the working copy
curl "$TONE_API/v1/agents/$AGENT/publish" -X POST -H "Authorization: Bearer $TONE_KEY"
# read the history
curl "$TONE_API/v1/agents/$AGENT/versions" -H "Authorization: Bearer $TONE_KEY"
# read exactly what ran on a call
curl "$TONE_API/v1/agents/$AGENT/versions/3" -H "Authorization: Bearer $TONE_KEY"
```
## Which version answers
| | Version used |
|---|---|
| An outbound call you place | Whatever is live when you place it |
| A running campaign | The version it launched with, for the whole run |
| An inbound call | Always the live version |
A running campaign finishing on its launch version is deliberate: a campaign is
one experiment, and changing the prompt halfway would make its results
uninterpretable. To change a running campaign, pause it, publish, and relaunch.
## What a version does and does not capture
A version pins the **set of knowledge-base ids** it was published with, not
their contents. Re-indexing a base changes what past versions answer with —
which is usually what you want, since the base is a source of truth rather than
a prompt.
Secrets are referenced, never copied. A version records that a tool authenticates
with secret X, not what X was at the time.
Versions are kept forever. Calls that predate the feature report
`agentVersion: null`, because "we do not know" is the truthful answer.
---
# Agents
Source: https://docs.usetone.ai/agents
> The thing that talks — a prompt, a voice, a language set, and the regulatory purpose that decides when it may call.
An agent is what your caller actually speaks to. It carries four things that
matter and several that are optional:
| | |
|---|---|
| `systemPrompt` | What it is trying to do, in your words |
| `voice` | Which model transcribes, which speaks, in which voice, in which languages |
| `purpose` | The regulatory sender classification — this decides *when* it may call |
| `status` | `draft` or `live` |
```bash
curl "$TONE_API/v1/agents" -X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "Order confirmation",
"purpose": "transactional",
"systemPrompt": "You call customers to confirm a cash-on-delivery order. Confirm the order, ask which day suits them for delivery, then thank them and end the call. Be brief and polite.",
"voice": {
"ttsModel": "bulbul:v3",
"ttsVoice": "simran",
"sttModel": "saaras:v3",
"languages": ["hi-IN", "en-IN"]
}
}'
```
## Write the task, not the manners
The platform already handles the conversational layer: interruptions, matching
the caller's language, waiting when they go quiet, ending the call when it is
over. A prompt that spends its words on "be polite and let the user finish" is
spending them twice.
Write what the call is *for*, what counts as done, and what the agent must not
promise. That is the part only you know.
## Purpose is not a label
🔴 `purpose` decides which calling window applies to every call this agent
makes, and whether consent is required before it dials at all.
| Purpose | When it may call (IST) | Consent required |
|---|---|---|
| `promotional` | 09:00 – 21:00 | Yes |
| `collections` | 08:00 – 19:00 | No |
| `service` | any time | No |
| `transactional` | any time | No |
It defaults to `promotional` — the strictest — precisely so that getting it
wrong fails safe. There is deliberately **no per-call override**: a request
field that relaxed the gate would make the whole audit trail worthless, because
a caller could simply opt out of being checked. See
[Compliance](/compliance).
## Draft and live
A new agent is always a draft, whatever you send.
- A **draft** can be dialled outbound. It will **not** answer inbound calls.
- **Publishing** freezes the working copy into an immutable numbered version and
points the agent at it.
Editing an agent changes nothing about calls in progress and nothing about what
answers your phone number. Publishing is the step that changes behaviour — see
[Agent versioning](/agent-versioning).
## Variables
`inputVariables` are `{{placeholders}}` the prompt can reference and each call
can fill:
```json
{ "inputVariables": [{ "name": "customer_name", "default": "there" }] }
```
```bash
curl "$TONE_API/v1/calls" -X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"agentId":"...","numberId":"...","toE164":"+919876543210",
"variables":{"customer_name":"Priya"}}'
```
Give every variable a default, even an empty-sounding one. A placeholder with
nothing behind it is how an agent ends up saying "your order ORDER_ID".
`outputVariables` run the other way: fields extracted from the transcript after
the call and returned as `outputs` on the call record. **The description is the
extraction instruction** — write it as you would tell a person what to look for,
not as a field label.
`evaluationCriteria` are questions a post-call judge answers about the call,
returned as `evaluations` with a verdict and a rationale each. Use them for "did
this call do its job", not for data you want back — that is what output
variables are for.
## Call settings
`callSettings` is how the call *runs* rather than what it says: the opening
line, background ambience, what to do when the caller goes quiet, voicemail
handling, a maximum duration and a farewell. Custom lines are translated into
the agent's languages automatically when you save.
Two of those are stripped on inbound calls, deliberately: an opening line is
written as "calling from X about Y" and would greet someone who rang *you*, and
voicemail detection is a callee-side judgement that would hang up on a real
person.
## Deleting
Deleting an agent unassigns its numbers rather than releasing them, and keeps
its call log. Deleting the agent must not erase the record of what it said.
---
# Authentication
Source: https://docs.usetone.ai/authentication
> API keys, the live/test split, scopes, and rotation without downtime.
Every request carries an API key as a bearer token:
```bash
curl "$TONE_API/v1/agents" \
-H "Authorization: Bearer tone_test_a1b2c3..."
```
There is no other authentication mode for the API. Keys never go in a URL or a query parameter —
URLs end up in access logs, browser history, and referrer headers.
## The environment is the prefix
```
tone_test_... the sandbox
tone_live_... real carriers, real money
```
This is structural, not a flag you pass. A `tone_test_` key routes every operation to the sandbox:
no carrier is contacted, no phone rings, no wallet is debited, and prices are simulated. A
`tone_live_` key does the real thing. Nothing in a request body can bridge the two — a test key
naming a live number fails with `environment_mismatch`, and so does the reverse.
The consequence worth planning around: **going to production is a key swap and nothing else.**
Test mode exercises the whole lifecycle — call states, dispositions, webhooks, CDR rows, priced
(but never charged) debits — so an integration that works in test works live.
Test keys are available from day one, before your KYC and DLT registration are complete. Live keys
unlock at verification. Build and ship your integration while the paperwork is in flight — see
[Test mode](/test-mode).
## Getting a key
Create keys in the dashboard under **Developer → API keys**, or programmatically:
```bash
curl "$TONE_API/v1/api-keys" \
-X POST -H 'content-type: application/json' \
-d '{"name":"orders-service","environment":"test","scopes":["write"]}'
```
`POST /v1/api-keys` authenticates with a **dashboard session, not a key**. Keys cannot mint keys —
a leaked key can do damage, but it cannot manufacture more credentials or escalate its own scope.
A key presented to this endpoint gets a `401`.
The response is the only time the full key exists:
```json
{
"data": {
"id": "7c3d...",
"name": "orders-service",
"environment": "test",
"scopes": ["read", "write"],
"start": "tone_test_a1b2",
"enabled": true,
"lastUsedAt": null,
"expiresAt": null,
"createdAt": "2026-08-24T09:41:07.812Z",
"key": "tone_test_a1b2c3d4..."
}
}
```
Store `key` in your secret manager immediately. We keep only a hash of it and the `start`
fragment; `GET /v1/api-keys` will show you the fragment forever and the key never again. If you
lose it, [roll it](#rotating-a-key).
Pass `expiresAt` (ISO 8601) at creation if the key should stop working on a date — useful for a
contractor's key or a time-boxed migration.
## Scopes
Three scopes, and each implies the ones below it:
| Scope | Grants | Typical holder |
|---|---|---|
| `read` | Every `GET`. Call logs, agents, numbers, wallet balance, events, compliance history. | Analytics, dashboards, monitoring |
| `write` | Everything `read` does, plus creating and updating agents, knowledge bases, campaigns, and placing calls | Your application |
| `admin` | Everything `write` does, plus **spending money and changing where data flows**: buying and releasing numbers, provisioning SIP trunks, managing webhook endpoints, reading signing secrets | Deployment automation, ops |
Omitting `scopes` at creation gives you `read` — least privilege by default.
A scope you don't have is a `403` with `error.code: "insufficient_scope"`, never a silent no-op.
Two rules constrain what a key can be:
- **A key can never out-rank the person who minted it.** Requested scopes are clamped to the
minting user's own role ceiling — Owners and Admins can mint `admin` keys, Developers can mint
up to `write`, everyone else up to `read`. This is re-checked on *every* request, not just at
creation, so a key cannot outlive a demotion.
- **`PATCH /v1/api-keys/:id` may only narrow scopes.** Widening is a new-key event: mint a
replacement and roll. A quietly edited old credential can never gain authority.
### Why buying a number needs `admin`
Purchasing provisions a real line with a real monthly rental, and releasing one takes down a
published business line that customers may be calling. Both are `admin`. Your application, holding
a `write` key, can create agents, place calls, and run campaigns all day — but cannot spend from
the wallet or delete the number those calls come from.
## Rotating a key
Rolling mints a replacement and keeps the old key working for an overlap window, so rotation never
requires a coordinated deploy:
```bash
curl "$TONE_API/v1/api-keys/7c3d.../roll" \
-X POST -H 'content-type: application/json' \
-d '{"expireOldIn":"24h"}'
```
`expireOldIn` accepts `now`, `1h`, `24h` (default), or `7d`. Both keys verify during the window;
deploy the new one at your own pace.
Use `now` when a key has leaked — that's the whole point of the setting. To kill a key outright:
```bash
curl -X DELETE "$TONE_API/v1/api-keys/7c3d..."
```
Revocation takes effect immediately: the response is a `204`, and the next request with that key is
a `401`.
## Keeping keys out of trouble
- **Rotate on a schedule** — a `7d` overlap makes quarterly rotation a non-event.
- **One key per system**, named after the system. When something needs revoking at 2am, you want
to revoke the thing that leaked, not everything.
- **Never in the browser.** A key in front-end JavaScript is a public key. Calls, campaigns, and
number purchases are all authorized by it. Put your server between your users and this API.
- **Never in a git repository.** If a key does land in one, roll it with `expireOldIn: "now"` —
scrubbing history is slower than the key is valuable.
## If a key leaks publicly
Tone participates in GitHub's secret-scanning partner program. If one of your keys is pushed to a
public repository, GitHub tells us within seconds and:
- **A live key is revoked immediately**, without waiting to ask. It can place calls, buy numbers
and spend your wallet, so we don't leave it alive for the length of an email. Anything using it
stops working — mint a replacement and deploy it. We email your owners and admins to say what
happened.
- **A test key is not revoked.** It reaches only the sandbox: it cannot spend money, ring a phone,
or read live data. Breaking your CI over it would do more harm than the leak. We email you so you
can rotate it when convenient.
Deleting the file afterwards is not enough on its own — the key remains in your git history, which
is why the credential itself has to be replaced rather than hidden.
---
# Wallet, pricing and billing
Source: https://docs.usetone.ai/billing
> Everything is prepaid, in paise, and the ledger is the source of truth.
Tone is prepaid. Calls, number purchases, monthly rentals and compliance verdicts
all debit one wallet, and everything is in **paise** (₹1 = 100 paise) because
money in floating point is money lost.
```bash
curl "$TONE_API/v1/wallet" -H "Authorization: Bearer $TONE_KEY"
curl "$TONE_API/v1/wallet/transactions" -H "Authorization: Bearer $TONE_KEY"
curl "$TONE_API/v1/wallet/usage?period=30d" -H "Authorization: Bearer $TONE_KEY"
```
## What a call costs
| | Rate | What you get |
|---|---|---|
| Full stack | **₹6.00/min** | Telephony + compliance + the agent |
| Bring your own voice | **₹2.50/min** | Telephony + compliance |
| Compliance only | per verdict | The gate, the ledger and the evidence |
Plus the monthly rental on each number.
The full-stack rate itemises as agent ₹3.50 + telephony ₹0.50 + platform ₹2.00.
⚠️ **That breakdown is display, not a billing model** — a call produces exactly
one wallet debit at the blended rate, not three.
🔴 A BYO call bills the telephony-plus-platform subset and **ignores a negotiated
full-stack rate**. A negotiated rate is a discount on the whole stack; applying it
to a call whose agent half we never ran would charge a discounted customer *more*
than list.
## Test mode is free by environment
A `tone_test_` key produces calls with `billedPaise: 0`. It is the **environment**
that makes a call free, never the channel — so a sandbox call is priced through
the same path and simply reports zero.
## The ledger is the truth
`GET /v1/wallet/transactions` is not a report derived from a balance; it is what
the balance is computed from. It is append-only: a correction is a new
compensating row, never an edit. If a balance and its ledger ever disagreed, the
ledger would be right.
| Entry type | |
|---|---|
| `topup`, `topup_reversal` | Money in |
| `call_usage` | One row per call |
| `number_purchase`, `number_rental`, and their reversals | Numbers |
| `compliance_usage` | Verdicts |
| `adjustment` | Anything we corrected by hand |
## Adding money
Funding runs through Razorpay Checkout **in a browser**, from the dashboard.
There is deliberately no programmatic money-in path: a card flow needs a human
and a redirect, and an API that pretended otherwise would only fail in more
places.
## Running out
Subscribe to `wallet.balance.low` rather than polling. It fires once per
crossing, not repeatedly, and emails owners and admins.
What happens at zero depends on what you were doing:
- **A call** fails rather than being placed.
- **A campaign** with `autoPauseBelowPaise` pauses itself with
`pausedReason: "low_balance"` and emits `campaign.paused`. Top up and resume.
- **A number rental** starts a grace period, then suspends the number — which
**keeps** your claim on it and reverses itself once you top up. See
[Phone numbers](/phone-numbers).
---
# Campaigns
Source: https://docs.usetone.ai/campaigns
> Call a list of people — pacing, retries, the legal calling window, and the pre-flight that tells you what will happen before it does.
A campaign calls a list of people with one agent, from one number, at a pace you set. It handles
concurrency, retries, the calling window, and the compliance checks on every single dial.
Use `POST /v1/calls` when your own system decides who to call and when — an order ships, a payment
fails, a customer clicks "call me". Use a campaign when you have a list and want it worked through.
A campaign is not a second dialling path. Every call it places goes through the same pre-dial
compliance gate, the same CDR, the same pricing and the same webhooks as a call you place yourself.
There is deliberately no way to dial from here that skips any of it.
Everything below runs in [test mode](/test-mode) for free. Use the
[magic numbers](/test-mode) as recipients and you get deterministic outcomes to assert on.
## Before you start
Two things must be true, and both are checked at **launch** rather than at create — so you can
build a draft campaign long before either is ready:
- **The agent is live.** A draft agent gets you `422` — *"… is still a draft. Set it live before
launching a campaign with it."*
- **The number routes to a Tone agent** (`routingMode: "tone_agent"`). A
[BYO number](/quickstart-byo) is refused at launch, not per dial: its calls are answered by
your stack, so a campaign on one would connect thousands of people to infrastructure this
campaign's agent never touches. You would otherwise discover that as several thousand mid-run
errors instead of one at the button.
## 1. Create the campaign
It starts as a `draft`. Nothing dials until you launch.
```bash
curl "$TONE_API/v1/campaigns" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "COD confirmations — March",
"agentId": "'"$AGENT"'",
"numberId": "'"$NUMBER"'",
"callsPerMinute": 30,
"maxConcurrent": 5,
"retryAttempts": 2,
"retryIntervalMinutes": 30,
"retryBackoff": "linear",
"retryOn": ["no_answer", "busy"],
"windowStartMinute": 600,
"windowEndMinute": 1140,
"weekdays": 31,
"autoPauseBelowPaise": 50000
}'
```
```json
{
"data": {
"id": "3b7e9a41-...",
"name": "COD confirmations — March",
"status": "draft",
"environment": "test",
"agent": { "id": "a4f21c8e-...", "name": "Order confirmation" },
"number": { "id": "9c1d0f22-...", "e164": "+918041234567" },
"purpose": null,
"schedule": {
"startsAt": null, "endsAt": null,
"windowStartMinute": 600, "windowEndMinute": 1140, "weekdays": 31
},
"pacing": { "callsPerMinute": 30, "maxConcurrent": 5 },
"retries": { "attempts": 2, "intervalMinutes": 30, "backoff": "linear", "on": ["no_answer", "busy"] },
"autoPauseBelowPaise": 50000,
"progress": { "total": 0, "dialed": 0, "connected": 0, "excluded": 0, "spentPaise": 0 },
"preflight": null,
"launchedAt": null, "completedAt": null, "pausedReason": null, "archivedAt": null
}
}
```
Save the `id` as `$CAMPAIGN`.
### The fields worth understanding
**The calling window is IST minutes from midnight.** `600` is 10:00, `1140` is 19:00. Not a
timezone-less time and not your server's zone: every recipient and every obligation here is
Indian, and the gate that blocks calls resolves in `Asia/Kolkata`. `weekdays` is a 7-bit mask with
Monday as bit 0, so `31` is Mon–Fri and `127` is every day. `0` is rejected — a campaign with no
enabled day can never dial, and accepting it would leave it `running` and silently idle.
**`callsPerMinute` caps at 60 and `maxConcurrent` at 20.** The first mirrors the carrier's own
campaign throttle, and bursts from one caller ID are exactly the shape TCCCPR's spam rubric
watches for. The second is the provider's per-account speech limit, shared across every one of
your API keys — a higher number would exhaust the provider before it exhausted anything of ours.
**`retryOn` cannot contain `answered`.** Redialling someone who already spoke to the agent is what
turns a campaign into a complaint, and under TCCCPR five complaints in ten days bars *every*
number your organization owns. The legal values are `no_answer`, `busy`, `failed` and `voicemail`.
**`autoPauseBelowPaise` is a floor, not a reservation.** When the wallet drops below it the
campaign pauses itself with `pausedReason: "low_balance"` and emits `campaign.paused`. `0` turns
the floor off, which means the campaign runs until the wallet is empty.
**`purpose` is null until launch.** It is snapshotted from the agent at that moment and never
refreshed, because it decides which calling window applied. A campaign that ran at 20:00 under a
`service` agent must keep saying `service` after someone flips that agent to `promotional` — the
alternative is a compliance record that retroactively accuses you of a violation you did not
commit.
## 2. Add recipients
Up to 500 per request. `variables` are the values substituted into your agent's prompt.
```bash
curl "$TONE_API/v1/campaigns/$CAMPAIGN/recipients" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"recipients": [
{ "e164": "+915555000001", "variables": { "order_id": "A-4471", "amount": "1499" } },
{ "e164": "+915555000002", "variables": { "order_id": "A-4472", "amount": "899" } }
]
}'
```
```json
{ "data": { "added": 2, "duplicates": 0, "total": 2 } }
```
**Retrying a batch is safe.** A number already on the campaign counts as a `duplicate` and is
skipped, not an error — so a client that never saw its response can simply send it again. That is
why this endpoint does not need an `Idempotency-Key`.
A campaign holds at most **20,000 recipients**; past that you get a `422` telling you to split the
list.
Recipients belong to the campaign, not to a shared contact list. Re-targeting the same audience is
`POST /v1/campaigns/{id}/duplicate`, which copies the campaign and its recipients back to a draft.
## 3. Run the pre-flight
The pre-flight tells you what will happen before anything dials: who gets excluded and why, what
the compliance checks say, and what it will cost.
```bash
curl "$TONE_API/v1/campaigns/$CAMPAIGN/preflight" \
-X POST -H "Authorization: Bearer $TONE_KEY"
```
```json
{
"data": {
"preflight": {
"computedAt": "2026-03-04T09:12:44.183Z",
"total": 2000,
"excluded": { "invalid_number": 3, "dnc": 12, "frequency_cap": 51, "duplicate": 0 },
"callable": 1934,
"checks": [
{ "type": "dlt", "outcome": "pass", "reason": "not_applicable" },
{ "type": "time_window", "outcome": "pass", "reason": null },
{ "type": "dnc", "outcome": "pass", "reason": null },
{ "type": "dnd_scrub", "outcome": "warn", "reason": "no_scrubber_configured" }
],
"estimatedCostPaise": { "low": 570000, "high": 1140000 },
"walletBalancePaise": 2500000
}
}
}
```
It is a `POST` because it computes and caches — but it places no calls and changes nothing else.
Launch is a separate, deliberate second step.
Each recipient is counted against exactly **one** reason — the first that applies — so the buckets
sum to `total`. Someone both unreachable and suppressed is reported as `invalid_number`, the more
specific fact and the one you can act on.
⚠️ **`duplicate` is always `0` here.** Duplicates never become recipient rows: the unique index on
`(campaign, number)` drops them at insert. The count you want is the `duplicates` field in the
add-recipients response — record it there, because the pre-flight cannot recompute what was never
stored.
**Read the `checks` before you launch.** A `block` makes launch a `422`; a `warn` does not, and is
information you are expected to act on. `no_scrubber_configured` is the honest answer when no
authoritative DND scrubber is wired up — it is never recorded as a pass.
A fresh pre-flight run also returns `excludedE164s`, grouping the excluded numbers by reason so you
can export them. It is never stored, so a later `GET /v1/campaigns/{id}` returns the counts alone.
## 4. Launch
```bash
curl "$TONE_API/v1/campaigns/$CAMPAIGN/launch" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H "Idempotency-Key: $(uuidgen)"
```
**`Idempotency-Key` is required here**, not merely honored. Without it you get a `400` naming the
header. A duplicate launch is a second pass over your recipient list: real people called twice,
which is a compliance problem before it is a billing one. Send the *same* key if a request times
out and you'll get the first result back rather than a second run — see
[Idempotency](/idempotency).
The campaign moves to `running` and the runner starts dialling within its window and pace.
## 5. Watch it
**Subscribe to webhooks** rather than polling. `call.answered`, `call.completed` and `call.failed`
arrive per call; `campaign.paused` and `campaign.completed` cover the run itself — including the
automatic wallet-floor pause, which is the one nobody thinks to watch for. See
[Webhooks](/webhooks).
Every call a campaign places carries **`campaignId`** on the call resource — in the webhook payload
and on `GET /v1/calls/{id}` alike — so attributing an event is a field read even with several
campaigns running at once. A call you placed yourself has `campaignId: null`.
```json
{ "id": "403cb084-...", "campaignId": "99ba451d-...", "disposition": "answered", "...": "..." }
```
Retries are attributed the same way: each attempt is its own call row carrying the same
`campaignId`, so you see all of them, not just the last. The same id filters the call log:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/calls?campaign_id=$CAMPAIGN&limit=100"
```
`GET /v1/calls/export.csv` takes it too, so a campaign's full call history — every attempt, with
durations and costs — is one request.
**Poll the campaign** for the cached counters:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN"
```
```json
{ "data": { "status": "running", "progress": {
"total": 2000, "dialed": 1240, "connected": 812, "excluded": 100, "spentPaise": 486300 } } }
```
**Read the outcomes** for the funnel, including what your agent actually established:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/outcomes"
```
```json
{
"data": {
"status": { "done": 1240, "pending": 660, "excluded": 100 },
"disposition": { "answered": 812, "no_answer": 301, "busy": 88, "voicemail": 39 },
"excludedReason": { "duplicate": 34, "dnc": 12, "invalid_number": 3, "frequency_cap": 51 },
"agentOutcome": {
"delivery_confirmed": { "yes": 604, "no": 122 },
"preferred_day": { "thursday": 288, "friday": 201, "saturday": 96 }
}
}
}
```
`agentOutcome` is the part worth building on. The keys and values are your agent's own declared
output variables, so "812 connected" becomes "604 confirmed, 122 declined" with no configuration
at all. It counts answered calls only, so *connected minus tallied* is the calls where extraction
found no clear answer.
**Per-recipient detail** is `GET /v1/campaigns/{id}/recipients`, filterable by `status`,
`disposition` and `excluded_reason`, cursor-paginated like every other list. Each row carries
`lastCallId`, which joins to `GET /v1/calls/{id}` for the transcript and recording. It names the
*last* attempt only — for every attempt, use `GET /v1/calls?campaign_id=`. For the whole recipient
list at once there is `GET /v1/campaigns/{id}/export.csv`, which includes the outcomes.
## 6. Pause, resume, stop
```bash
curl -X POST -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/pause"
curl -X POST -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/resume"
curl -X POST -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/stop"
```
`paused` is resumable and is what both your Pause and the wallet floor produce. `stopped` is
terminal and abandons the remaining recipients. They are deliberately different states, because
otherwise "why did this stop overnight?" has no answer — check `pausedReason` for it: `operator`,
`low_balance`, `target_unavailable` or `carrier_errors`.
Calls already in flight are not cut off by a pause; it stops new dials.
## The rules that bite
**The calling window must fit your purpose — including the retry tail.** `promotional` is
09:00–21:00 IST (TCCCPR's preference band) and `collections` is 08:00–19:00 (RBI's recovery-agent
circular, tighter than anything TRAI imposes). `service` and `transactional` have no window.
The pre-flight checks that your window *plus the retry tail* fits inside the legal one. Two retries
at 30 minutes is a 60-minute tail, so a promotional campaign may end no later than 20:00. A window
ending at 20:55 with that ladder would place its last retry at 21:55 — outside the band, and the
single easiest violation to prove. You get `time_window: block` with reason
`retry_tail_outside_calling_window`, and launch refuses.
**`maxConcurrent` above your quota does not queue.** The overflow is refused and those recipients
settle as failed. Check `GET /v1/limits` for your real ceiling before raising it.
**The agent can never be changed after create.** `PATCH` refuses `agentId` outright. Changing the
agent changes the purpose, which changes the legal window — and a campaign that dialled half its
list under one window and half under another produces a compliance record nobody can read, which
is the record's whole job. Point a new campaign at the other agent. Everything else freezes too
once the campaign leaves `draft`/`scheduled`.
**Recipients are excluded, not silently dropped.** `invalid_number`, `dnc`, `frequency_cap`,
`dnd_scrub` and `blocked_by_gate` each appear in the funnel with a count, and every excluded
recipient keeps its row with an `excludedReason` you can filter on.
**The frequency cap spans campaigns, not just this one.** Anyone your organization called in the
recent window is excluded, because two overlapping campaigns calling the same person in a week is
exactly the complaint pattern TCCCPR reg. 25 counts against you.
## Compliance evidence
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$CAMPAIGN/compliance"
```
Every check the gate ran, for every attempt, in the order it ran them — passes included, not just
refusals. This is the artefact you hand your access provider when a complaint is investigated.
Under TCCCPR reg. 25, five complaints in ten days bars every telecom resource a sender owns
"irrespective of whether those telecom resources were actually used", so the defence is a
per-attempt record of what was checked and what it returned. A log of only refusals proves nothing
about the calls that went through — which are exactly the calls being complained about.
For one number rather than one campaign, `GET /v1/compliance/evidence?e164=` assembles the same
evidence across checks, consent history and suppression. See
[Compliance as an API](/quickstart-compliance).
## Where to go next
- **[Webhooks](/webhooks)** — the event catalog and how to verify a signature.
- **[Test mode](/test-mode)** — magic numbers that give a campaign deterministic outcomes.
- **[Errors](/errors)** — the `blocked_*` family, `insufficient_funds`, and
`concurrent_call_limit_reached`.
---
# Changelog
Source: https://docs.usetone.ai/changelog
> Every change to the /v1 contract. Additive only — see the versioning policy for what that guarantees.
Changes are additive: nothing listed here removes or retypes anything you
already receive. See [Versioning](/versioning) for what that commits us to.
Subscribe by watching [`openapi.json`](https://docs.usetone.ai/openapi.json) —
it is regenerated from the code and is the machine-readable record of everything
below.
---
## 2026-08-25 — First published
The initial `/v1` contract: **92 operations** an API key can call, **12 webhook
events**, and **32 error codes**.
### Added
- **Agents** — create, publish, roll back, and read any published version.
Every call pins the version that ran it.
- **Phone numbers** — search carrier inventory, buy, route to a Tone agent or
your own stack, and read the rental state.
- **Calls** — place outbound, receive inbound, and read the detail record,
transcript and recording.
- **Campaigns** — 20 operations covering recipients, pre-flight, launch,
pacing, retries and outcomes.
- **Compliance** — the pre-dial gate as a standalone verdict API, a consent
ledger, suppression lists, outcome ingestion and evidence packs.
- **Knowledge bases** — documents an agent answers from, with a retrieval
preview that runs the same path a live turn runs.
- **Wallet** — balance, ledger and usage by category.
- **Webhooks** — endpoint management, per-endpoint secrets with 24-hour
rotation overlap, delivery history and replay.
- **Limits** — request budget and both concurrency pools.
### Conventions established
- `Idempotency-Key`, **required** on number purchase and campaign launch.
- Rate-limit headers on every keyed response, successes included.
- `doc_url` on every error, pointing at [the catalog](/errors).
- Every enum published as an **open set** — see [Versioning](/versioning).
---
## How an entry gets written
The contract is diffed on every change:
```bash
pnpm --filter backend openapi:diff --markdown
```
That produces the mechanical half — what was added, and whether anything was
breaking. The prose is written by hand, because "new field `agentVersion`" is
not the same as "a call now records which version of the agent ran it, so a
finished call stays explainable after the prompt changes".
---
# The pre-dial gate
Source: https://docs.usetone.ai/compliance
> Five checks that run before every call, and the audit rows they leave whether they pass or block.
India regulates who may be called, about what, and when. TCCCPR liability sits
with the sender — you — and there is no statutory safe harbour for using a
vendor. What Tone can do is run the checks and keep the evidence that you ran
them.
## What runs, in order
Every dial — direct, campaign, or BYO — runs all six, inside the same
transaction as the call record.
| # | Check | Can block? | |
|---|---|---|---|
| 1 | `dlt` | no — warns | Your registration and whether the from-number suits the purpose |
| 2 | `a2p` | no — warns | Whether an auto-dialer pre-declaration is on file with your access provider |
| 3 | `time_window` | yes | The calling band for the agent's purpose, in IST |
| 4 | `dnc` | yes | Your suppression list — **the only list we hard-block on** |
| 5 | `carrier_dnd` | **never** | Carrier metadata, advisory only |
| 6 | `consent` | no — warns, promotional only | Whether consent is on record. A valid consent also overrides a carrier DND flag — never a suppression |
Each writes a row with an outcome of `pass`, `warn` or `block`, **whatever it
decided**. The table is append-only at the database level: nothing can edit or
delete a check after the fact, which is the entire point of having one.
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/compliance/checks?e164=%2B919876543210&outcome=block"
```
## The A2P pre-declaration
TCCCPR regulation 4 requires notifying your Originating Access Provider, in
writing and in advance, that auto-dialer / robo-call technology is in use.
File the declaration with your access provider, then record its reference
under **Compliance → Auto-dialer pre-declaration** — the `a2p` check warns on
every call placed without one, on every purpose, and each evidence record
cites the declaration when it exists.
## Why carrier DND only warns
The authoritative Do-Not-Call register is deliberately not exposed to
telemarketers — that is its design, not an oversight. What a carrier returns is
metadata, and the carrier disclaims it. Blocking on advisory data would silently
drop legitimate calls and give you no way to tell which; passing it through
labelled as advisory lets you decide.
🔴 **Tone does not claim authoritative NCPR scrubbing**, and you should not
either. What we block on is your own suppression list plus cross-customer
opt-outs, labelled as such.
## Calling windows
Narrowed from the industry default of 08:00 because we cannot read an individual
recipient's registered preference band, so the safe assumption is the tightest
one.
| Purpose | Window (IST) | Basis |
|---|---|---|
| `promotional` | 09:00 – 21:00 | TCCCPR preference band |
| `collections` | 08:00 – 19:00 | RBI recovery-agent rules |
| `service` | none | Schedule II — may not be blocked |
| `transactional` | none | Schedule II — may not be blocked |
There is **no override**, and no request field reaches the gate. A caller who
could opt out of being checked would make every audit row meaningless.
## Where purpose comes from
From durable configured state, never from the request:
- A **Tone agent** supplies its own `purpose`.
- A **BYO number** has no agent, so the organization's declared sender
classification is used. Set it in the dashboard under **Telephony → Compliance
→ Sender classification**; dialling before declaring one is a `422`.
## Checking without dialling
If you dial on your own carrier, ask for the verdict directly. It writes the same
audit rows a dial writes, and answers `200` either way — a refusal is a verdict,
not an error.
```bash
curl "$TONE_API/v1/compliance/check" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","purpose":"promotional"}'
```
See [Compliance as an API](/quickstart-compliance).
## When a call is blocked
`403`, with `error.code` naming which check stopped it:
| Code | From |
|---|---|
| `blocked_dnd` | `dnc`, `carrier_dnd` or a stale `scrub` |
| `blocked_opt_out` | `consent`, `time_window`, or a recorded outcome |
| `blocked_dlt_invalid` | `dlt` or `a2p` |
`error.details` carries `checkType` and `reason`, and the audit row is already
written — so a block is fully explainable after the fact from
`GET /v1/compliance/evidence`.
## Sealed evidence records
Beyond the raw audit rows, every call attempt — completed calls **and refused
dials** — produces one sealed evidence record: the legal basis to call (your
DNC result, the consent record relied on, the carrier signal), how the call
was placed (DLT registration, A2P declaration, calling window, the number's
status at dial time, whether the AI disclosure was spoken and when), and the
outcome.
Each record carries a sha256 of its canonical content plus the previous
record's hash, forming a per-organisation chain: editing any record breaks its
own hash, and deleting or reordering one breaks every later link.
```bash
# the record for one call, or the same as a PDF at /evidence.pdf
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/calls/{id}/evidence"
# prove the chain's integrity, on demand
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/compliance/evidence-records/verify"
```
The table is append-only at the database level, and its rows survive even an
organisation deletion. Records seal shortly after a call ends; a refused dial
seals in the same transaction as its refusal.
---
# Consent and suppression
Source: https://docs.usetone.ai/consent-and-dnc
> Two different ledgers. Only one of them stops a call — and knowing which is the difference between a compliant estate and a fine.
🔴 **The consent ledger passes or warns. It never blocks. Only the suppression
list blocks.**
That is the single most important sentence on this page. Revoking someone's
consent does not stop them being called; it records that consent ended. If
someone asks not to be called, they must land on the suppression list.
## Opt-out does both, in one transaction
```bash
curl "$TONE_API/v1/consent/opt-out" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","reason":"Asked during a call"}'
```
This revokes **every** active consent for the number *and* suppresses it under
the 90-day re-consent lockout, atomically. It is one verb rather than two calls
precisely because doing half of it is the failure mode that produces complaints.
The same function runs when a caller opts out mid-call, and when you report an
`opt_out` outcome — so a mixed estate keeps one suppression list.
## Recording consent
```bash
curl "$TONE_API/v1/consent" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","purpose":"promotional","kind":"explicit",
"source":"web_form","capturedAt":"2026-08-20T11:02:00Z",
"evidenceRef":"form-sub-88213"}'
```
Consent is **purpose-scoped**: a record for `promotional` does not satisfy the
gate for `collections`.
🔴 **`capturedAt` is when the RECIPIENT consented, not when you called this
endpoint.** It defaults to now, which is right for a live capture and wrong for
an import — importing a back catalogue without it dates every record to the day
of the import and makes the 7-day transactional clock meaningless.
| `kind` | Expiry |
|---|---|
| `explicit` | Policy fills it — capped at 7 days for a transactional purpose, otherwise until revoked |
| `inferred` | **You must supply `expiresAt`.** It lasts as long as the relationship, and only you know when that ends. Omitting it is a `422` |
Records are append-only. Revoking stamps the record; it does not delete it,
because the evidence that consent once existed is as important as the fact it
ended.
## Suppression
```bash
# one
curl "$TONE_API/v1/dnc" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","source":"manual","lockout90d":true}'
# bring an existing list — up to 1000 per request, one transaction
curl "$TONE_API/v1/dnc/bulk" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"entries":[{"e164":"+919876543210"},{"e164":"+919812345678"}]}'
```
Adding a number already on the list is a no-op, not an error — a replayed webhook
or a retried job cannot fail here.
`lockout90d` records an opt-out that becomes contactable again on a date, which
is what TCCCPR describes. Without it, the suppression is permanent — which is
what a complaint earns.
⚠️ Removing an entry makes the number dialable again. An opt-out is a legal
instruction, not a preference: remove one only when you can show the recipient
asked to be contacted again. **Lifting a suppression is a dashboard-session
action with a written reason** (`POST /v1/dnc/{id}/remove`) — API keys can add
suppressions within seconds of an opt-out, but cannot lift one, and every
removal is recorded as a `suppression_change` row in the audit trail. The
entry itself survives with `removedAt` stamped rather than disappearing. (The
old `DELETE /v1/dnc/{id}` still exists and always refuses, saying so.)
Numbers can also land on the list **automatically**: a DTMF-9 keypress during
a call, a verbal "stop calling me" the post-call analysis detects, and a
carrier reporting the number permanently unreachable each write an entry with
the call recorded as its source.
## Recording consent, and its limits
Consent for a number inside its 90-day opt-out lockout is refused with a
`409` — TCCCPR forbids re-acquiring consent during it, and accepting the
record would hand the gate a pass that contradicts the suppression. A
permanently suppressed number (a complaint) requires lifting the suppression
first.
A valid **explicit** consent also does one more thing at dial time: it
overrides an advisory carrier-DND flag (consent legally beats the preference
register). It never overrides your suppression list.
```bash
# bring an existing consent base — up to 1000 per request, accepted PER ROW
curl "$TONE_API/v1/consent/bulk" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"records":[{"e164":"+919876543210","purpose":"promotional","kind":"explicit","source":"import","capturedAt":"2026-06-01T10:00:00Z"}]}'
```
Bulk import accepts rows individually: a record that violates policy (an
inferred consent missing `expiresAt`, a locked-out number) comes back in
`rejected` with its index and reason while the rest land. 🔴 Set `capturedAt`
on every imported record — it defaults to now, which dates your whole back
catalogue to the day of the import.
## Closing the loop from your own dialer
```bash
curl "$TONE_API/v1/compliance/call-outcomes" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","outcome":"opt_out","checkId":"8814"}'
```
`opt_out` and `complaint` are not just labels — they suppress the number through
the same path a mid-call opt-out uses. `opt_out` applies the 90-day lockout;
`complaint` suppresses permanently.
## The evidence pack
When a complaint arrives, this is the answer:
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/compliance/evidence?e164=%2B919876543210"
```
Every check, consent record, suppression entry and reported outcome Tone holds
for that number, with timestamps.
---
# Conventions
Source: https://docs.usetone.ai/conventions
> Base URL, the response envelope, pagination, casing, and rate limits — the things that are true of every endpoint.
Everything on this page holds for every endpoint, so the reference pages don't repeat it.
## Base URL
```
https://apibeta.usetone.ai
```
Every path is under `/v1`. The examples in these docs use a shell variable so you can point them
at whichever environment you're working against:
```bash
export TONE_API=https://apibeta.usetone.ai
export TONE_KEY=tone_test_...
```
### `/v1` is a promise, not a version number
There will be no `/v2`. Changes to this API are additive: new endpoints, new optional request
fields, new response fields, new enum members. We will not remove a field you receive, rename one,
change a type, or make an optional request field required.
The practical consequence for your code: **treat every enum as open.** A `disposition` you have
never seen, a new `status`, a new webhook `type` — these can appear without warning and are not
breaking changes. Handle the unknown case (log it, ignore it, fall through to a default) rather
than throwing. A `switch` with no `default` is the most common way an integration breaks against
an additive API.
The contract is enforced mechanically: `backend/openapi.json` is committed and diffed on every
change, so a field cannot disappear by accident.
## Authentication
`Authorization: Bearer tone_live_...` or `Authorization: Bearer tone_test_...` on every request.
See [Authentication](/authentication).
## The response envelope
Every successful response is wrapped:
```json
{ "data": { "id": "8f14...", "status": "queued" } }
```
List endpoints add `meta.pagination`:
```json
{
"data": [ { "id": "8f14..." }, { "id": "1c92..." } ],
"meta": { "pagination": { "cursor": "eyJpZCI6...", "hasMore": true, "limit": 25 } }
}
```
Two exceptions, both deliberate: CSV exports (`/v1/calls/export.csv` and friends) return the CSV
itself, and binary downloads (a call recording) return the bytes. Wrapping either would corrupt
the download.
Errors replace `data` entirely — see [the error catalog](/errors).
## Casing: bodies are camelCase, query params are snake_case
This split is intentional and consistent:
```bash
# query params: snake_case
curl "$TONE_API/v1/calls?agent_id=$AGENT&include_unstarted=true&limit=50" ...
# request bodies: camelCase
curl "$TONE_API/v1/calls" -d '{"agentId":"...","numberId":"...","toE164":"+919876543210"}'
```
Response bodies are camelCase, matching request bodies. The snake_case exceptions in responses are
the developer-contract fields that are snake_case everywhere in this API: `error.type`,
`error.code`, `error.doc_url`, `error.request_id`, and the webhook envelope's `created_at`.
**Query strings are strict.** A misspelled parameter is a `400`, not a silent no-op. This is on
purpose: `agentId=...` instead of `agent_id=...` would otherwise quietly return your entire
unfiltered call log, which is the worst possible outcome for a filter.
## Pagination
Cursor-based, newest first. Pass `limit` (1–100, default 25), then feed
`meta.pagination.cursor` back as `cursor` until `hasMore` is false.
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/calls?limit=100"
# → meta.pagination.cursor = "eyJpZCI6..."
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/calls?limit=100&cursor=eyJpZCI6..."
```
The cursor is opaque — don't parse it, don't construct one, don't store it as a bookmark past the
end of a paging session. Cursors are used instead of offsets so that pages stay stable while new
rows arrive: with `?page=2`, a call that lands mid-scan shifts every subsequent row and you silently
skip one.
Some list endpoints also return `meta.pagination.total` (the suppression list does, where "how many
numbers am I blocking?" is a real question). Most don't: on an append-only log, a `COUNT(*)` is a
second query whose answer is stale before you read it.
## Timestamps, money, and phone numbers
- **Timestamps** are RFC 3339 / ISO 8601 strings in UTC: `"2026-08-24T09:41:07.812Z"`.
- **Money is always paise**, always an integer, always named `...Paise` — `billedPaise: 480` is
₹4.80. There are no floats in this API's money path. `currency` is `INR`.
- **Phone numbers are E.164** with the country code and a leading `+`: `+919876543210`. This is
both what you send and what you receive. A number without the `+`, or with spaces, is a `400`.
## Rate limits
Two budgets, and they fail in different ways for different reasons.
**Requests per minute** — a per-organization token bucket, separate per environment. Every keyed
response carries both header families:
```
RateLimit-Policy: "org";q=600;w=60
RateLimit: "org";r=594;t=41
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 594
X-RateLimit-Reset: 1756029667
```
The first pair is the IETF structured-field form (`q` = quota, `w` = window seconds, `r` =
remaining, `t` = seconds until reset); the `X-` pair is the older GitHub-style family, where
`X-RateLimit-Reset` is an absolute epoch second rather than a duration. Use whichever your HTTP
client already understands — they describe the same bucket.
Exhausting it returns `429` with `error.code: "rate_limited"`, a `Retry-After` header, and
`error.details.retry_after_seconds`. Back off and retry — a 429 here means slow down.
**Concurrent calls** — how many calls can be in flight at once, counted in two separate pools
(Tone-agent calls and BYO calls). Exceeding it returns `error.code: "concurrent_call_limit_reached"`,
which is deliberately a different code from `rate_limited` because the fix is different: you are
not sending requests too fast, you have too many calls up. Wait for calls to end, or raise the quota.
Read both budgets, live, at any time:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/limits"
```
```json
{
"data": {
"environment": "test",
"requests": { "limitPerMinute": 300, "remaining": 297 },
"concurrency": {
"agentCalls": { "limit": 10, "inUse": 2 },
"byoCalls": { "limit": 10, "inUse": 0 }
}
}
}
```
Don't hardcode these numbers — read them. They differ between `test` and `live`, and they change
when your plan does.
## Request IDs
Every response — success or failure — carries a `request_id`, and every error envelope repeats it
in the body. Log it. Quoting one in a support conversation turns "a call failed yesterday" into a
single row in our logs.
## Machine-readable
Everything on this site is available in a form a tool can read.
| | |
|---|---|
| [`openapi.json`](/openapi.json) | The OpenAPI 3.1 spec — 92 operations, every field described, with example bodies. Import it directly into Postman, Insomnia or an SDK generator. |
| [Postman collection](/tone.postman_collection.json) | The same thing, pre-converted, with the bearer token as a collection variable. |
| [`llms.txt`](/llms.txt) | An index of every page, [llmstxt.org](https://llmstxt.org) convention. |
| [`llms-full.txt`](/llms-full.txt) | Every guide, in full, in one file. |
| Any page + `.md` | The Markdown of that page — [`/errors.md`](/errors.md). |
| `/mcp` | An MCP server, so a coding agent can read these docs in your editor. Two tools, no credential needed. |
The spec is generated from the running code and diffed on every change, so it
cannot describe an API we do not have. See [Versioning](/versioning).
---
# Error catalog
Source: https://docs.usetone.ai/errors
> Every error code this API can return, what causes it, and what to do about it.
Every error has the same shape:
```json
{
"error": {
"type": "compliance",
"code": "blocked_dnd",
"message": "That number is on your Do-Not-Call list.",
"details": { "checkId": "8814" },
"doc_url": "https://docs.usetone.ai/errors#blocked_dnd",
"request_id": "req_8f14a2c9..."
}
}
```
**Branch on `code`, never on `message`.** Codes are part of the contract — we add them, we don't
rename them. Messages are written for humans and get reworded.
`type` is the coarse category (`invalid_request`, `authentication`, `authorization`, `not_found`,
`conflict`, `rate_limit`, `compliance`, `payment_required`, `server_error`) — useful for a
catch-all branch when you meet a code you don't know. `details` is present when there's structured
context worth having. `doc_url` links to this page, at the anchor for that code. `request_id` is
what to quote if you contact us.
Codes will be added over time. Handle an unrecognised one by falling back on `type` and the HTTP
status.
## Retrying
| | Retry? |
|---|---|
| `429` `rate_limited` | Yes — after `Retry-After` |
| `429` `concurrent_call_limit_reached` | Yes — but only once calls have ended |
| `409` `idempotency_key_in_use` | Yes — after a short delay |
| `503` `provider_unavailable` | Yes — with backoff, and **keep your idempotency key** |
| `5xx` `internal_error` | Yes — with backoff |
| Everything else (`4xx`) | No. Fix the request. |
`provider_unavailable` is the one that deserves care: it means the carrier's answer left the
outcome genuinely unknown. Retry with the **same** `Idempotency-Key` — that's precisely the case
idempotency exists for. A fresh key risks buying two numbers.
---
## What `details` carries
Most errors omit it. Where it is present it is structured, and it is the part
worth logging:
| Code | `details` |
|---|---|
| `validation_error` | `{ "issues": [...] }` — one entry per field that failed, with the path and the reason |
| `blocked_dnd`, `blocked_opt_out`, `blocked_dlt_invalid` | `{ "checkType": "dnc", "reason": "..." }` — which of the five gate checks refused, and why |
| `rate_limited` | `{ "retry_after_seconds": 41 }` — the same figure as the `Retry-After` header |
```json
{
"error": {
"type": "compliance",
"code": "blocked_dnd",
"message": "That number is on your Do-Not-Call list.",
"details": { "checkType": "dnc", "reason": "opt_out recorded 2026-08-20" },
"doc_url": "https://docs.usetone.ai/errors#blocked_dnd",
"request_id": "req_8f14a2c9..."
}
}
```
A blocked dial has **already written its audit rows** by the time you see this,
so `GET /v1/compliance/evidence?e164=...` explains it in full after the fact.
## Where each code comes from
Errors that can appear on any endpoint — `validation_error`, `unauthenticated`,
`forbidden`, `insufficient_scope`, `rate_limited`, `not_found`, `internal_error`
— are omitted here. These are the ones tied to a particular thing you were doing:
| Code | Raised by |
|---|---|
| `blocked_dnd`, `blocked_opt_out`, `blocked_dlt_invalid` | `POST /v1/calls`, campaign dialling, `POST /v1/compliance/check` |
| `concurrent_call_limit_reached` | `POST /v1/calls`, campaign dialling |
| `insufficient_funds` | `POST /v1/calls`, `POST /v1/numbers/purchase`, rental renewal, `POST /v1/compliance/check` |
| `compliance_incomplete` | `POST /v1/numbers/purchase` |
| `number_unavailable`, `provider_*` | `POST /v1/numbers/purchase`, `GET /v1/numbers/available` |
| `knowledge_base_not_ready` | Dialling an agent whose knowledge base has never built |
| `document_too_large` | `POST /v1/knowledge-bases/{id}/documents/upload-url` |
| `idempotency_key_in_use`, `idempotency_key_reused_with_different_params` | `POST /v1/numbers/purchase`, `POST /v1/campaigns/{id}/launch`, `POST /v1/calls` |
| `environment_mismatch` | Any route where a `tone_test_` key names a live resource, or the reverse |
| `email_*`, `*_token`, `mfa_required` | Sign-up and sign-in only. Not reachable with an API key |
## Request problems
### `bad_request`
`400`. The request was malformed in a way that isn't a field-level validation failure — a missing
required header, for example. `POST /v1/numbers/purchase` and campaign launch return this when
`Idempotency-Key` is absent; the message names the header.
### `validation_error`
`400`. A field failed validation. `details` names the fields and what was wrong with each.
Common causes: a phone number that isn't E.164 (`+919876543210`, no spaces), a timestamp that isn't
ISO 8601, a `(ttsModel, ttsVoice)` pair that doesn't exist, or a **misspelled query parameter** —
query strings are strict, so `agentId` where `agent_id` was meant is a `400` rather than a silently
ignored filter.
### `unprocessable_entity`
`422`. The request was well-formed but doesn't make sense against current state. The `message` is
specific and worth surfacing. Frequent cases:
- Naming an `agentId` on a BYO number, or omitting one on a Tone-agent number — the number's
routing mode decides which shape is right.
- Dialling a number that isn't `active`.
- Dialling a BYO number with no media endpoint configured.
- Placing a BYO call before setting your organization's sender classification.
- Dialling on a SIP-routed number, which your own platform dials through directly.
### `not_found`
`404`. No such resource — or it belongs to another organization, which we report identically on
purpose. Also returned for an event older than the 30-day retention window.
### `conflict`
`409`. The request collided with the world. Most often a number that someone else bought between
your search and your purchase.
### `idempotency_key_in_use`
`409`. Another request with this `Idempotency-Key` is still running. Wait and retry; you'll get the
first request's response. See [Idempotency](/idempotency).
### `idempotency_key_reused_with_different_params`
`422`. This key was already used with a different request body. We refuse rather than replaying the
first body under a new request's name. Use a fresh key for a genuinely different operation.
### `environment_mismatch`
`422`. A `tone_test_` key naming a live resource, or a `tone_live_` key naming a sandbox one. This
is structural — no request field bridges it. Check which key your process loaded.
---
## Authentication and authorization
### `unauthenticated`
`401`. No credential, or one we couldn't verify. Check the header form:
`Authorization: Bearer tone_live_...`.
### `invalid_credentials`
`401`. The credential was read but rejected — a revoked or expired key, or a wrong password on the
dashboard sign-in path.
### `forbidden`
`403`. Authenticated, but not allowed. Usually a session-only endpoint reached with an API key:
minting keys, starting a top-up, and managing integration connections are deliberately dashboard-only.
### `insufficient_scope`
`403`. Your key's scopes don't cover this operation. Buying or releasing numbers, provisioning SIP
trunks, managing webhook endpoints, and reading signing secrets all need `admin`; writes need
`write`. Mint a key with the right scope — you can't widen an existing one.
### `invalid_signature`
`401`. A signed service-to-service request failed verification, was replayed, or arrived outside
the clock-skew window.
The five codes below belong to the sign-up and sign-in paths and are **not reachable with an API
key**. They are listed individually because every error envelope carries
`doc_url: .../errors#`, and a shared heading would give them all the same anchor.
### `email_not_verified`
`403`. The credential was correct, but the account's email address has not been verified yet. Send
the user back through the OTP step; a fresh code can be requested from the sign-in screen.
### `email_exists`
Sign-up with an address that is already registered. Note that the sign-up endpoint itself answers
*generically* when email verification is required — it will not tell you whether an address exists,
because that is an account-enumeration oracle. The dashboard therefore pre-checks with
`POST /v1/signup/email-available` (public, no credential) and surfaces this code from that result
rather than from the sign-up call.
### `invalid_token`
`400`. A verification, password-reset or invitation token that did not parse, was already consumed,
or does not belong to this account. Tokens are single-use — issue a fresh one.
### `token_expired`
`400`. The token parsed but is past its validity window. Request a new one and retry.
### `mfa_required`
**Reserved — nothing returns this today.** It is declared in the registry so clients can branch on it
before multi-factor sign-in ships, which is the same additive-only promise `/v1` makes everywhere
else. Handle it as an authentication failure if you meet it.
---
## Compliance
These are the codes an Indian voice product exists to enforce. Treat them as normal outcomes on a
real recipient list, not as exceptions — a `403` here is the system working.
Every refusal writes an audit row, so `GET /v1/compliance/checks` explains any block after the fact,
and `GET /v1/compliance/evidence?e164=...` assembles the full record for one number.
### `blocked_dnd`
`403`. The recipient is on **your organization's** do-not-call list — added manually, imported, or
written automatically when someone opted out on a call. Suppression from an opt-out lasts 90 days.
Note that carrier DND data is *advisory* in this gate: it flags a call, it never blocks one. So this
code always means your own list. Inspect it at `GET /v1/dnc`.
### `blocked_opt_out`
`403`. Blocked by consent, calling window, or an enforcement/profile state. `message` says which:
- **No consent on record** — promotional calls need consent that hasn't expired. Explicit
transactional consent is capped at 7 days by TCCCPR 2025; inferred consent must carry its own
expiry.
- **Outside the permitted calling window** — Indian rules restrict when promotional and collections
calls may be placed. This has no override, deliberately. Schedule the call instead.
### `blocked_dlt_invalid`
`403`. Your DLT registration isn't approved yet. Required before promotional traffic can go out;
complete it in the dashboard. DLT approval is a regulator-side process with no API — it takes as
long as it takes.
---
## Money
### `insufficient_funds`
`402`. The wallet can't cover this. For a number purchase, that's the setup fee plus the first
month's rental. Campaigns pause themselves rather than fail when the balance gets low — subscribe
to `wallet.balance.low` and you'll hear about it first.
Top-ups are a dashboard action: funding is deliberately human, so an automated loop can't drain a
card.
---
## Limits
### `rate_limited`
`429`. Too many requests this minute for your organization. Honour `Retry-After`;
`details.retry_after_seconds` carries the same number. `GET /v1/limits` shows your current budget.
### `concurrent_call_limit_reached`
`429`. Too many calls in flight at once — a different problem from `rate_limited`, which is why it
has its own code. Slowing down your requests won't help; you need calls to end, a lower campaign
`maxConcurrent`, or a raised quota. Agent calls and BYO calls draw from separate pools, both visible
at `GET /v1/limits`.
---
## Carrier and provisioning
### `number_unavailable`
`409`. Someone bought that number between your search and your purchase. Search again and pick
another — carrier inventory is genuinely first-come.
### `provider_unavailable`
`503`. The carrier couldn't be reached, or answered in a way that leaves the outcome **unknown**.
This is not "it failed" — it's "we don't know". Retry with the same `Idempotency-Key`.
### `provider_quota_exhausted`
`429`. The carrier's own daily provisioning cap is spent. Not something you can retry your way out
of today; try tomorrow, or contact us if you need a larger allocation.
### `provider_misconfigured`
`500`. Our carrier credentials are wrong. This one is on us, it's alarmed on our side, and no
change to your request will fix it. Please do get in touch with the `request_id`.
### `compliance_incomplete`
`403`. Buying a live number requires completed GST/CIN details and signatory KYC. Finish the
verification wizard under **Telephony → Compliance** in the dashboard. Test mode needs none of
this — build against it while the paperwork is in flight.
---
## Knowledge bases
### `knowledge_base_not_ready`
`409`. The base is attached to an agent but no build has succeeded yet, so there's nothing to answer
from. Builds are asynchronous: wait for `knowledge.build.completed`, or poll the base until its
status settles.
### `document_too_large`
`413`. The upload exceeds the per-file cap. Split it.
---
## Ours
### `internal_error`
`5xx`. Something broke on our side. Retry with backoff. If it persists, send us the `request_id` —
it identifies the exact request in our logs, which turns a support thread into a lookup.
---
# A BYO media session
Source: https://docs.usetone.ai/flows/byo-session
> What happens on the wire between Tone and your own voice stack, from dial to hang-up.
See [Bring your own voice stack](/quickstart-byo) for setup and
[Tone Media Streams](/tone-media-streams) for the protocol reference.
## The setup, once
| # | Step | Where |
|---|---|---|
| 1 | Declare your sender classification | Dashboard |
| 2 | Store the endpoint credential | Dashboard — **Developer → Secrets** |
| 3 | `PATCH /v1/numbers/{id}` with `routingMode: "byo_ws"` and `mediaEndpoint` | API |
Steps 1 and 2 are dashboard-only and will `403` with an API key, deliberately.
## Then, per call
| # | What happens |
|---|---|
| 1 | `POST /v1/calls` **without `agentId`** — sending one is a `422` |
| 2 | The compliance gate runs, exactly as it does for an agent call |
| 3 | The carrier dials |
| 4 | Tone opens a WebSocket to your `mediaEndpoint` |
| 5 | A `start` frame arrives with the call id, direction, from, to and your `customParameters` |
| 6 | `media` frames flow both ways until someone hangs up |
| 7 | Tone writes the call record and bills at the BYO rate |
Inbound needs no extra work: a BYO number answers inbound the same way.
## The endpoint
**`static`** names one `wss://` URL, used for every call with the call id
appended as a query parameter. **`webhook`** names an `https://` URL fetched per
call that answers `{"url":"wss://…"}` — which is how you route calls to different
backends.
🔴 Both must be publicly reachable. Private and link-local addresses are refused
when you save the config, and refused **again at connect time after DNS
resolution** — because the first check can be sidestepped by DNS and the second
by a config that was never validated.
## Three rules that decide whether it sounds right
- **Send small chunks**, 20–100ms. One large buffer is a caller waiting.
- **Use `mark`** to know when your audio finished playing. Guessing from byte
counts drifts.
- **Hang up by closing the socket.** There is no separate hang-up frame.
`customParameters` merge in three layers, later winning: static number config →
per-call `variables` → identity facts (`callId`, `direction`, `from`, `to`).
## Every refusal reports a completed call
If Tone cannot reach you, the call still ends properly — it is never left hanging
in your CDR:
| `endReason` | |
|---|---|
| `refused_no_endpoint` | The number is `byo_ws` with no `mediaEndpoint` |
| `refused_endpoint_unreachable` | Your endpoint did not accept the connection |
| `refused_endpoint_forbidden` | It resolved into private address space, or refused the credential |
| `refused_media_format` | The negotiated audio format is not one we speak |
| `max_duration` | The 60-minute ceiling |
Each writes a call record with a `failed` disposition. Watch for these before
blaming the carrier.
## Two limitations
**Campaigns do not run on BYO numbers** — refused at launch rather than as
thousands of per-call errors.
**A negotiated full-stack rate does not apply.** BYO bills the
telephony-plus-platform subset; see [Billing](/billing).
---
# Campaign lifecycle
Source: https://docs.usetone.ai/flows/campaign-lifecycle
> Draft to finished, and every state it can be in on the way.
See [Campaigns](/campaigns) for the full field reference.
| # | Call | State after |
|---|---|---|
| 1 | `POST /v1/campaigns` | `draft` |
| 2 | `POST /{id}/recipients` — up to 500 per request | `draft` |
| 3 | `POST /{id}/preflight` | `draft` — nothing changes |
| 4 | `POST /{id}/launch` **with `Idempotency-Key`** | `running` |
| 5 | `POST /{id}/pause` / `resume` | `paused` / `running` |
| 6 | Last recipient settles, or `POST /{id}/stop` | `completed` |
## Pre-flight is free and tells you everything
It returns the exclusion buckets — `invalid_number`, `dnc`, `frequency_cap`,
`duplicate`, `dnd_scrub`, `blocked_by_gate` — with each recipient counted against
exactly one reason, plus the compliance checks, an estimated cost band and your
balance. It changes nothing.
A `block` verdict makes launch a `422`. A `warn` does not.
⚠️ `duplicate` always reads `0` here — duplicates are dropped when recipients are
added, so the real count is in the add-recipients response.
## Launch is the point of no return
🔴 `Idempotency-Key` is **required**; without it, `400`. Launching twice dials
the whole list twice, which is exactly the complaint pattern TCCCPR punishes.
Launch also refuses when:
- the agent is still a **draft**,
- the number routes to **your own stack** — refused at the button rather than as
thousands of per-call `422`s,
- the calling window plus the **retry tail** would fall outside the legal band,
- **scrubbing fails**. A scrubber outage fails the launch with a `503` rather
than passing an unscrubbed list. That is the one outcome TCCCPR forbids.
Launch snapshots the agent, the number and the purpose. The run finishes on the
version it launched with.
## While it runs
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/campaigns/$C/outcomes"
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/calls?campaign_id=$C"
```
The recipient list names each recipient's **last** attempt. For every call
including retries, filter the call log by `campaign_id`.
It can pause itself: below `autoPauseBelowPaise` it stops with
`pausedReason: "low_balance"` and emits `campaign.paused`. Top up, then resume.
## Changing a running campaign
You cannot. `agentId` can never change, and everything else freezes once the
campaign leaves draft. Pause, publish your prompt change, and relaunch — or
`POST /{id}/duplicate`, which copies the campaign and its recipients into a fresh
draft.
## After it finishes
`POST /{id}/recipients/{recipientId}/redial` queues one settled recipient for
another attempt, and a completed campaign wakes up for it. `POST /{id}/archive`
hides it from the list without deleting anything.
---
# Verdict to evidence
Source: https://docs.usetone.ai/flows/compliance-evidence
> The loop a compliance-only integration runs, and what it can prove afterwards.
If you dial on your own carrier, Tone never sees the call. What it can hold is the
record that you asked before dialling, what it was told, and what happened next —
which is what a complaint is answered with.
| # | Call | What it records |
|---|---|---|
| 1 | `POST /v1/compliance/check` | Five audit rows and a verdict, before you dial |
| 2 | *You dial, on your own carrier* | — |
| 3 | `POST /v1/compliance/call-outcomes` | What happened, tied to the verdict by `checkId` |
| 4 | `GET /v1/compliance/evidence?e164=` | Everything above, assembled |
## 1. Ask
```bash
curl "$TONE_API/v1/compliance/check" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","purpose":"promotional"}'
```
`200` either way — a refusal is a verdict, not an error. `allowed` is the answer;
`blockedBy` names the check that stopped it; `checks[]` carries the persisted
rows, each with a citable `id`.
Batch up to 100 with `POST /v1/compliance/check/batch`. Each number runs the full
gate and is metered as its own verdict.
## 2. Dial
Tone is not in this step. The verdict is advisory to your dialer — it does not
stop you, and honouring it is the point of asking.
## 3. Report back
```bash
curl "$TONE_API/v1/compliance/call-outcomes" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","outcome":"connected","checkId":"8814",
"callReference":"acme-crm-4417","durationSeconds":45}'
```
`checkId` is what turns two records into one story. `callReference` is your own
id, echoed back so you can reconcile against your CDR.
🔴 `opt_out` and `complaint` are not labels. They suppress the number through the
same path a mid-call opt-out uses — `opt_out` applies the 90-day lockout,
`complaint` suppresses permanently — and revoke every consent on record in the
same transaction. That is what keeps one suppression list across a mixed estate.
## 4. Prove it
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/compliance/evidence?e164=%2B919876543210"
```
Every check, consent record, suppression entry and reported outcome, with
timestamps and a `truncated` flag that tells you honestly when there was more.
## What this does and does not give you
It gives you a contemporaneous, append-only record that you checked, what you
were told, and what you did — which is the thing you cannot reconstruct after a
complaint arrives.
🔴 It is **not** a safe harbour. TCCCPR liability stays with the sender and the
Principal Entity; there is no statutory protection for having used a vendor. And
the DNC verdict is your own list plus Tone's cross-customer suppression plus
carrier metadata **labelled as such** — not authoritative NCPR scrubbing, which
is not available to telemarketers at all.
Claiming otherwise is the line we will not cross, and neither should you.
## SIP has no pre-dial hook
A `byo_sip` number is dialled by your platform straight to the carrier, so there
is nothing for Tone to intercept. The verdict API *is* your pre-flight; the
carrier CDR, synced back as priced call records, is the evidence.
---
# Your first call, end to end
Source: https://docs.usetone.ai/flows/first-call
> Every call in order, from a test key to a call record you can read back.
Nothing here costs money or rings a phone. See [Quickstart](/quickstart) for the
same path with the responses shown.
| # | Call | Why |
|---|---|---|
| 1 | `GET /v1/limits` | Confirms the key works and shows your budgets |
| 2 | `POST /v1/agents` | Creates the agent — always as a draft |
| 3 | `POST /v1/numbers` | Allocates a free sandbox number |
| 4 | `PATCH /v1/numbers/{id}` | Binds the agent, so the number has an answer |
| 5 | `POST /v1/calls` | Places the call. Runs the compliance gate |
| 6 | `GET /v1/calls/{id}` | Reads the outcome |
## Where it goes wrong
| Symptom | Cause |
|---|---|
| `401` on step 1 | Wrong key, or it was revoked |
| `403 insufficient_scope` on step 2 | The key was minted `read`-only. Scopes cannot be widened — mint a new key |
| `400 validation_error` on the voice | The `(ttsModel, ttsVoice)` pair does not exist. See [Voices](/voices) |
| `422` on step 5 | `agentId` sent for a BYO number, or omitted for an agent number |
| `403 blocked_*` on step 5 | The gate refused. `error.details.checkType` says which check |
| Call `ended` with no transcript | Normal in test mode unless you dialled a magic number that answers |
## Publishing
Step 2 leaves a **draft**. A draft can be dialled outbound, which is why this
flow works without publishing — but it will not answer inbound calls. Publish
before you expect the number to work in both directions:
```bash
curl "$TONE_API/v1/agents/$AGENT/publish" -X POST -H "Authorization: Bearer $TONE_KEY"
```
## Then stop polling
Step 6 is fine while you are exploring, and wrong in production. Register a
webhook endpoint and subscribe to `call.completed` — see
[Webhooks](/webhooks).
---
# Going live
Source: https://docs.usetone.ai/flows/going-live
> What has to be true before a real phone rings, and in what order.
Everything you built against a `tone_test_` key works unchanged. Going live is a
key swap — but a few things must be true first, and some of them take days
rather than minutes.
## The order that saves time
| # | Step | Where | Wait |
|---|---|---|---|
| 1 | Business identity — GSTIN or PAN, CIN if you have one | Dashboard | Minutes |
| 2 | Authorised-signatory KYC | Dashboard | Minutes to hours |
| 3 | DLT Principal Entity ID | Dashboard, after registering with an operator | **Days** — start it first |
| 4 | Declare your sender classification | Dashboard | Minutes |
| 5 | Fund the wallet | Dashboard, via Razorpay | Minutes |
| 6 | Buy a number | API | Minutes |
| 7 | Mint a `tone_live_` key | Dashboard | Seconds |
**Start step 3 on day one.** DLT registration involves an external operator and
is the only step measured in days. Everything else can happen while it is in
flight, and all of steps 1–5 are deliberately dashboard-only: they are decisions
an accountable person makes, not something a deploy script does.
## Why those are not API routes
An API key cannot complete KYC, declare your sender classification, or spend
money. Each of those is a statement about who your company is and what it is
allowed to do — and a key that could make them would let a compromised key make
them too. Calling them with `$TONE_KEY` gets a `403`, deliberately.
## The swap
```diff
- TONE_KEY=tone_test_...
+ TONE_KEY=tone_live_...
```
That is the whole change. Same endpoints, same request shapes, same responses,
same webhook signatures.
## What actually changes
| | Test | Live |
|---|---|---|
| Calls | Fixed outcomes, nobody speaks | Real audio, real people |
| `billedPaise` | `0` | Real money |
| Numbers | Free from the sandbox pool | Bought, with monthly rent |
| Compliance gate | Runs, with the same rules | Runs |
| Webhooks | Sent, signed identically | Sent |
🔴 **Agents and knowledge bases are shared across both environments; calls and
numbers are not.** One agent object serves both universes, so publishing a change
in test publishes it for live. Calls and numbers are environment-scoped, and a
`tone_test_` key structurally cannot read live calls — `environment_mismatch` if
you try.
## Before your first real campaign
- Check `GET /v1/limits` and set `maxConcurrent` at or below your quota.
- Set `autoPauseBelowPaise` so a campaign stops rather than failing mid-run.
- Load your existing suppression list via `POST /v1/dnc/bulk`.
- Confirm your calling window and retry tail fit the legal band for your purpose,
or launch will refuse.
---
# Retrying safely after a timeout
Source: https://docs.usetone.ai/flows/idempotent-retry
> A timeout tells you nothing about whether the work happened. This is how you find out without doing it twice.
Three operations spend money or dial a stranger, and for those a timeout is the
dangerous case — not because it failed, but because you cannot tell whether it
did.
| Operation | `Idempotency-Key` |
|---|---|
| `POST /v1/numbers/purchase` | **Required** — `400` without it |
| `POST /v1/campaigns/{id}/launch` | **Required** — `400` without it |
| `POST /v1/calls` | Optional, strongly recommended |
## The rule
Generate a key per **logical** operation, not per attempt. Retry with the *same*
key.
```js
const key = crypto.randomUUID(); // once, outside the retry loop
async function buy(e164) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(`${API}/v1/numbers/purchase`, {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.TONE_KEY}`,
'content-type': 'application/json',
'idempotency-key': key, // ← the SAME key every attempt
},
body: JSON.stringify({ e164 }),
});
if (res.ok) return res.json();
const { error } = await res.json();
if (error.code === 'idempotency_key_in_use') { // 409 — first one still running
await sleep(1000 * 2 ** attempt);
continue;
}
if (res.status >= 500 || error.code === 'provider_unavailable') {
await sleep(1000 * 2 ** attempt);
continue;
}
throw new Error(`${error.code}: ${error.message}`);
}
}
```
Persist the key alongside whatever prompted the operation. A key held only in
memory is gone exactly when the process crashed mid-request — the one case it
existed for.
## What each response means
| | |
|---|---|
| `2xx` with `Idempotent-Replayed: true` | The first attempt succeeded. This is its stored response — not new work |
| `409 idempotency_key_in_use` | The first attempt is still running. Wait and retry |
| `422 idempotency_key_reused_with_different_params` | Same key, different body. Almost always a bug: a key reused across two genuinely different operations |
| `503 provider_unavailable` | 🔴 The carrier's answer left the outcome **unknown** — not "it failed". Retry with the **same** key. A fresh key risks buying two numbers |
Keys are remembered for **24 hours**. A handler that throws releases its key, so
a genuine error can be retried after fixing the request.
## Where it does not help
Idempotency protects the operation, not your bookkeeping. If you generate a new
key because your own record of the first attempt was lost, you get two numbers
and two rentals. The durable key is the whole mechanism.
---
# Number lifecycle
Source: https://docs.usetone.ai/flows/number-lifecycle
> Search, buy, route, renew, suspend, reactivate, release — and which of those are reversible.
| State | Meaning | Reversible |
|---|---|---|
| `pending` | Bought; the carrier has not finished provisioning | — |
| `active` | Working in both directions | — |
| `suspended` | Unpaid rent. Refuses calls, **keeps your claim on the number** | Yes — top up |
| `released` | Returned to carrier inventory | 🔴 **No** |
| `failed` | Provisioning did not complete | — |
## Buying
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/numbers/eligibility"
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/numbers/available?region=KA"
curl "$TONE_API/v1/numbers/purchase" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H "Idempotency-Key: $(uuidgen)" \
-H 'content-type: application/json' -d '{"e164":"+918045678901"}'
```
Inventory is live. A number taken between your search and your purchase is a
`409 number_unavailable` — search again.
The number arrives **unrouted**. Bind an agent, or set a BYO endpoint, before it
can do anything.
## The monthly anniversary
Purchase debits setup plus the first month and sets `nextRentalAt` one month out.
Anniversaries clamp at month end: bought on 31 January, it renews 28 February and
stays on the 28th.
## When rent cannot be paid
| Day | What happens | Event |
|---|---|---|
| Anniversary | Debit fails; grace period starts; owners and admins emailed | — |
| Past grace | Number **suspended** — refuses calls both ways | `number.suspended` |
| Wallet topped up | **Reactivates automatically** | `number.reactivated` |
🔴 Suspension is not release. The number is still yours, still costs the overdue
rent, and comes back on its own. Freeing it would sell your published line to a
stranger.
## Releasing
```bash
curl "$TONE_API/v1/numbers/$NUMBER" -X DELETE -H "Authorization: Bearer $TONE_KEY"
```
Permanent, immediate and unrecoverable. If your goal is to stop paying, do
nothing — non-payment suspends, and suspension reverses.
## Changing who answers
```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://media.example.com/tone"}}'
```
Switching modes keeps the bound agent (ignored while BYO, restored on switching
back) and never reprices history — each call snapshots its own mode.
---
# Webhook delivery and rotation
Source: https://docs.usetone.ai/flows/webhook-delivery
> What we send, how often we retry, and the one thing that breaks verification during a rotation.
See [Webhooks and events](/webhooks) for the event catalog.
## A delivery
| Header | |
|---|---|
| `x-tone-event` | The event type |
| `x-tone-event-id` | **Stable across retries — dedupe on this** |
| `x-tone-timestamp` | Unix seconds, covered by the signature |
| `x-tone-signature` | One or more space-delimited `v1=` signatures |
| `x-tone-delivery-attempt` | `1` on the first send |
Verify `HMAC-SHA256` of the literal `{timestamp}.{raw body}` — the **raw** body,
before any JSON parsing. Re-serialising first changes the bytes and the signature
will never match.
The URL path is deliberately not signed, so a proxy or a path rewrite in front of
your endpoint does not break verification.
## Answer within 10 seconds
Do the minimum: verify, enqueue, return `2xx`. A slow handler does not merely
time out — it manufactures duplicates, because a retry is indistinguishable from
a first delivery to a handler that already did the work.
| Your response | We |
|---|---|
| `2xx` | Consider it delivered |
| `4xx`, except `408` and `429` | Give up — you said it was malformed |
| `5xx`, `408`, `429`, timeout | Retry |
## The retry ladder
1, 2, 4, 8, 16, 32, 64, 128 minutes, then every 4 hours — **14 attempts over
roughly a day**, each with ±20% jitter.
⚠️ **20 consecutive failures across all deliveries disables the endpoint**, with
an email to owners and admins. Re-enable it with a `PATCH` once you have fixed the
receiver.
## Rotating the secret
```bash
curl "$TONE_API/v1/integrations/webhooks/$ID/roll-secret" -X POST \
-H "Authorization: Bearer $TONE_KEY"
```
🔴 **During the 24-hour overlap, `x-tone-signature` carries SEVERAL space-delimited
signatures.** A verifier that treats the header as one string starts rejecting
everything the moment you rotate. Split on whitespace and accept the delivery if
**any** signature matches:
```js
const ok = header.split(' ').some((sig) =>
timingSafeEqual(Buffer.from(sig), Buffer.from(`v1=${expected}`)));
```
Write it that way before you ever rotate, not during.
## When you miss one
Deliveries are at-least-once and unordered. If your receiver was down past the
retry window:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/events?type=call.completed"
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/integrations/webhooks/deliveries"
```
30-day retention. `POST /deliveries/{id}/retry` re-sends immediately with the
same event id.
⚠️ `GET /v1/events/{id}` **re-renders the payload from current state** rather
than replaying the signed bytes. A transcript that landed after delivery is
present here and was absent from what your endpoint received. Use it to
understand an event, not to re-verify a signature.
---
# Idempotency
Source: https://docs.usetone.ai/idempotency
> Retry any write safely — the same Idempotency-Key returns the first result instead of doing the work twice.
A network timeout tells you nothing about whether the request ran. Send it again and you might buy
a second phone number; don't, and you might have bought none. Idempotency keys settle it: retry as
many times as you like, and exactly one of them does the work.
```bash
curl "$TONE_API/v1/numbers/purchase" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H "Idempotency-Key: 7f3a0c9e-2b41-4d8a-9f10-4c2e6b7d1a55" \
-H 'content-type: application/json' \
-d '{"e164":"+918041234567","label":"support line"}'
```
Retry that exact request — same key, same body — and you get the same `201` and the same number
back, plus a header marking it as a replay:
```
Idempotent-Replayed: true
```
No second number is bought, and no second rental starts.
## Generate the key on your side, per logical operation
Use a UUID v4 — `crypto.randomUUID()`, `uuid.uuid4()`, whatever your runtime offers. The rules:
- **One key per operation you intend to happen once**, not per HTTP attempt. Generate it *before*
the first attempt and reuse it for every retry of that same operation. A fresh key per retry
defeats the whole mechanism.
- **A new operation gets a new key.** Buying a second number tomorrow is a new operation.
- Keys are scoped to your organization and live for **24 hours**. After that the key is forgotten
and reusing it starts a new operation, so don't use one as a permanent deduplication record —
that's what the resource's own `id` is for.
If you're persisting the operation anyway (a job row, an outbox record), store the key on it. Then
a worker that crashes and restarts picks up the same key and finishes the job exactly once.
## Where it is required
Two endpoints **refuse** a request without the header, with a `400` that says so:
| Endpoint | Why it's mandatory |
|---|---|
| `POST /v1/numbers/purchase` | A duplicate purchase is a second number and a second monthly rental — an ongoing liability, not a one-time charge |
| `POST /v1/campaigns/:id/launch` | A duplicate launch is a second pass over your recipient list: real people called twice, and a compliance problem as well as a billing one |
The header is **honored, not required,** everywhere else it applies — most importantly
`POST /v1/calls` and wallet top-ups. Send it there too. There is no downside, and a retried dial
without one is a second phone call to a real person.
Read requests (`GET`) are naturally idempotent and ignore the header.
## The three failure modes
### Same key, same body → replay
You get the original response — same status, same body — with `Idempotent-Replayed: true`. This
includes errors: if the first attempt failed with a `402`, the replay is that same `402`. The first
attempt's outcome is the operation's outcome.
### Same key, different body → `422`
```json
{
"error": {
"type": "invalid_request",
"code": "idempotency_key_reused_with_different_params",
"message": "This Idempotency-Key was already used with a different request.",
"doc_url": "https://docs.usetone.ai/errors#idempotency_key_reused_with_different_params",
"request_id": "req_8f14a2..."
}
}
```
This is a bug in your code, and we refuse rather than guess. The alternative — silently replaying
the *first* body — is the classic footgun: you ask to buy number B, get number A back, and your
records disagree with reality forever. The fix is a fresh key for the changed request.
### Same key, still running → `409`
```json
{ "error": { "code": "idempotency_key_in_use", "...": "..." } }
```
Two of your workers raced on the same key. Wait a moment and retry: the first request is still
executing, and once it finishes your retry will replay its stored response.
## What the body has to match
The comparison is over the request body, normalized so key order doesn't matter — `{"a":1,"b":2}`
and `{"b":2,"a":1}` are the same request. Whitespace doesn't matter either. Anything else that
differs — a changed value, an added field — is a different request.
## What this does not protect against
Idempotency keys make a *retry* safe. They don't deduplicate two genuinely separate operations that
happen to look alike: if your code calls `POST /v1/calls` twice with two different keys, that's two
calls, and we place both. Deduplicating your own intent is your side of the line — which is exactly
why the key should be generated where the intent is formed, not where the HTTP request is sent.
---
# Inbound calls
Source: https://docs.usetone.ai/inbound-calls
> What happens when somebody rings a number you own.
Inbound needs no API call. Bind an agent to a number and it answers:
```bash
curl "$TONE_API/v1/numbers/$NUMBER" -X PATCH \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d "{\"agentId\":\"$AGENT\"}"
```
The call appears in your call log with `direction: "inbound"` and
`initiator: "inbound"`, and fires the same webhooks as any other call.
## Four things that differ from outbound
**A draft agent will not answer.** Outbound will happily dial with a draft;
inbound refuses. Publish before you expect your line to work.
**Inbound always uses the live version**, even while a campaign elsewhere is
running on an older one.
**The pre-dial gate does not run.** Calling windows and Do-Not-Call protect a
recipient from a call they did not ask for. Answering is not placing, and
refusing to answer your own published line at 21:01 would be absurd. No
`compliance_checks` rows are written for inbound calls.
**The opening line and voicemail handling are stripped.** An opening line reads
"calling from X about Y" and would greet someone who rang *you*; voicemail
detection is a callee-side judgement that would hang up on a real person.
## When a call is refused
Some inbound calls are refused before they become calls. **A refusal writes no
call record at all** — the call never happened, so inventing a row for it would
be a lie in your CDR.
| Reason | Fix |
|---|---|
| The number has no agent bound | Bind one |
| The bound agent is a draft | Publish it |
| The number is `suspended` | Top up; it reactivates on its own |
| Insufficient balance | Top up |
| At your concurrency quota | See below |
⚠️ **Inbound and outbound currently share one concurrency pool.** A campaign
running at your quota can lock out your published line. Keep campaign
`maxConcurrent` below your quota if the same organization takes inbound calls
that matter.
## Environment comes from the number
An inbound call presents no credential of yours, so there is no key to read an
environment from. It is taken from the number instead: a number allocated in test
mode produces test calls.
---
# Knowledge bases
Source: https://docs.usetone.ai/knowledge-bases
> Documents an agent can answer from, without putting them in the prompt.
A knowledge base is a set of documents an agent retrieves from mid-call.
Attaching one is how an agent answers "what's your returns window?" without that
answer living in the prompt.
## The shape of it
```bash
# 1. create
curl "$TONE_API/v1/knowledge-bases" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"name":"Shipping and returns policy"}'
# 2. import a page, or upload a file (see below)
curl "$TONE_API/v1/knowledge-bases/$KB/documents/url" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"url":"https://example.com/help/returns"}'
# 3. attach it to an agent
curl "$TONE_API/v1/agents/$AGENT" -X PATCH \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d "{\"knowledgeBaseIds\":[\"$KB\"]}"
```
## Builds are asynchronous
Indexing runs in the background and swaps atomically — the old index keeps
serving until the new one succeeds. Status is derived, not stored:
| Status | Meaning |
|---|---|
| `empty` | No documents yet |
| `indexing` | A build is running; the previous index still answers |
| `ready` | At least one build has succeeded |
| `error` | The last build failed. The previous index, if any, still answers |
**Subscribe to `knowledge.build.completed` and `knowledge.build.failed` rather
than polling.** Attaching a base that has never built successfully and then
dialling gives `409 knowledge_base_not_ready` — there is nothing to answer from.
## Uploading a file
Uploads are two-phase, because nothing about a file is trusted until we have read
it ourselves:
1. `POST /documents/upload-url` — declare the filename, type and **exact** byte
length. The length is signed into the URL; a presigned PUT that does not pin
it is an unbounded write.
2. `PUT` the bytes to the URL you get back.
3. `POST /documents/{id}/confirm` — we re-read the object, check its real size
and type against what you declared, and queue it for indexing.
## Test what the agent will actually get
```bash
curl "$TONE_API/v1/knowledge-bases/$KB/search" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"query":"How long do I have to return an item?"}'
```
This runs the **same retrieval a live turn runs** — a preview that queried
differently would be worse than no preview. It optionally generates the answer a
caller would hear.
`degraded: true` in the response means embeddings were unavailable and it fell
back to keyword search alone. The results are real, just weaker.
## Writing documents that retrieve well
Retrieval works on passages, so structure beats prose. Headings that name the
question ("Returns window", "COD refunds") retrieve far better than a wall of
text, because the heading travels with the passage.
---
# Limits and quotas
Source: https://docs.usetone.ai/limits
> Two budgets that fail in different ways, and need different responses.
```bash
curl "$TONE_API/v1/limits" -H "Authorization: Bearer $TONE_KEY"
```
```json
{
"data": {
"environment": "live",
"requests": { "limitPerMinute": 600, "remaining": 594 },
"concurrency": {
"agentCalls": { "inUse": 2, "limit": 10 },
"byoCalls": { "inUse": 0, "limit": 10 }
}
}
}
```
Live and test have **separate budgets**, and this answers for whichever key you
asked with.
## Requests per minute
A per-organization token bucket. Every keyed response carries the headers, on
successes too — a client that only reads them on a `429` has already been
throttled:
```
RateLimit-Policy: "org";q=600;w=60
RateLimit: "org";r=594;t=41
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 594
X-RateLimit-Reset: 1756029667
```
Exceeding it is `429 rate_limited` with a `Retry-After`. **Slow down and retry.**
Dashboard traffic does not spend this budget, so a busy browser tab cannot
throttle your integration.
## Concurrent calls
Two pools that do not borrow from each other:
| Pool | |
|---|---|
| `agentCalls` | Calls running a Tone agent |
| `byoCalls` | Calls bridged to your own media stack |
Exceeding one is `429 concurrent_call_limit_reached` — a **different failure**
that slowing down does not fix. You have to wait for calls to end.
🔴 A campaign's `maxConcurrent` **does not queue** above your quota. Recipients
dialled over it are refused and settle as `failed`, which looks like bad phone
numbers rather than a configuration mistake. Read `GET /v1/limits` and keep
`maxConcurrent` at or below what it reports.
⚠️ Inbound and outbound currently share the agent pool. A campaign at your quota
can lock out your published line.
## Handling both
```js
if (res.status === 429) {
const { error } = await res.json();
if (error.code === 'rate_limited') {
await sleep(Number(res.headers.get('retry-after')) * 1000);
return retry(); // submitting too fast
}
if (error.code === 'concurrent_call_limit_reached') {
return queueForLater(); // too many calls live — waiting won't help soon
}
}
```
Both are `429`, and treating them the same means either hammering a full
concurrency pool or needlessly stalling on a rate limit. Branch on `error.code`.
Need more? Both are raised per organization — ask.
---
# Outbound calls
Source: https://docs.usetone.ai/outbound-calls
> Placing a call, and reading what happened.
```bash
curl "$TONE_API/v1/calls" -X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'content-type: application/json' \
-d "{\"agentId\":\"$AGENT\",\"numberId\":\"$NUMBER\",\"toE164\":\"+919876543210\"}"
```
`agentId` is **required** on a number that routes to a Tone agent and
**forbidden** on a BYO number — the number decides which, and either mistake is a
`422`.
## What happens before the carrier is called
The [compliance gate](/compliance) runs inside the same transaction as the call
record. So a refusal is a `403` with the audit rows already written and no
carrier contacted — the block and its evidence are the same event.
One thing runs *before* the gate: your concurrency quota. Over it, you get
`429 concurrent_call_limit_reached` with no verdict recorded, because no dial was
attempted. That is a different failure from a rate limit and needs a different
response — see [Limits](/limits).
## Lifecycle
| `status` | |
|---|---|
| `queued` | Accepted; the carrier has not connected it yet |
| `in_progress` | Connected |
| `ended` | Finished. `disposition` now says how |
| `disposition` | |
|---|---|
| `answered` | A person picked up |
| `no_answer` | Rang out |
| `busy` | Engaged |
| `voicemail` | An answering machine took it |
| `failed` | The carrier could not complete it |
| `unknown` | We genuinely do not know |
Treat both as **open sets** — new values can appear and are not a breaking
change.
Two honesty rules worth knowing: a call abandoned in `queued` reads as ended
after 15 minutes, and one stuck `in_progress` past 65 minutes reads as ended with
`unknown` and **bills nothing**. We would rather report "we do not know" than
invent an outcome or charge for one.
## Reading it back
Poll if you must, but the call log is not where you should learn a call ended:
```bash
curl "$TONE_API/v1/calls/$CALL" -H "Authorization: Bearer $TONE_KEY"
```
Subscribe to `call.completed` instead. See [Webhooks](/webhooks).
To find every call one campaign placed — **retries included** — filter the call
log rather than reading the recipient list, which names only each recipient's
last attempt:
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/calls?campaign_id=$CAMPAIGN&limit=100"
```
## Test mode
The magic numbers produce fixed outcomes, so CI can assert on them, and nothing
rings. See [Test mode](/test-mode).
---
# Phone numbers
Source: https://docs.usetone.ai/phone-numbers
> Rent a real +91 line, decide who answers it, and keep it as long as you pay the rent.
A number is the only part of Tone that costs money whether or not you use it, and
the only part with a monthly anniversary. That shapes most of its behaviour.
## Getting one
In **test mode** a number is free and instant, and no carrier is contacted:
```bash
curl "$TONE_API/v1/numbers" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"series":"regular"}'
```
In **live mode** you search real carrier inventory and buy:
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/numbers/available?number_type=landline®ion=KA"
curl "$TONE_API/v1/numbers/purchase" -X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'content-type: application/json' \
-d '{"e164":"+918045678901","label":"Support line"}'
```
🔴 **`Idempotency-Key` is required here.** Without it you get a `400`. A duplicate
purchase buys a second number and starts a second monthly rental, and a timeout is
indistinguishable from a purchase that never happened — so retry with the *same*
key. See [Idempotency](/idempotency).
Buying needs completed business verification. `GET /v1/numbers/eligibility`
answers before you try.
Inventory is live, so results go stale: a number taken between your search and
your purchase is a `409 number_unavailable`. Search again and pick another.
## Who answers it
`routingMode` decides that, per number — one organization can mix all three.
| Mode | Who answers | Rate |
|---|---|---|
| `tone_agent` | A Tone agent you built | ₹6.00/min |
| `byo_ws` | Your own stack, over a WebSocket | ₹2.50/min |
| `byo_sip` | Your own SIP platform | see below |
```bash
curl "$TONE_API/v1/numbers/$NUMBER" -X PATCH \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d "{\"agentId\":\"$AGENT\"}"
```
Switching to a BYO mode **keeps** the bound agent — it is ignored while BYO and
restored when you switch back. Switching mode never reprices past calls: each
call snapshots its own mode at creation.
⚠️ A `byo_sip` number is **not dialable** through `POST /v1/calls`; the platform
reaches the carrier directly, so there is no pre-dial hook. Use
[the verdict API](/quickstart-compliance) as your pre-flight instead.
## Rent, and what happens when you stop paying
Purchase debits setup plus the first month and sets an anniversary. On each
anniversary the rental is debited. Anniversaries clamp at month end, so a number
bought on 31 January renews on 28 February and stays on the 28th thereafter.
If the wallet cannot cover it:
1. A **grace period** starts, with an email to owners and admins on day one.
2. Past grace the number is **suspended** — it refuses calls in both directions
and emits `number.suspended`.
3. Top up, and it **reactivates on its own**, emitting `number.reactivated`.
🔴 **A suspended number keeps your claim on the line.** Unpaid rent is not a
release: freeing the number would sell your published phone number to a stranger.
## Releasing
```bash
curl "$TONE_API/v1/numbers/$NUMBER" -X DELETE -H "Authorization: Bearer $TONE_KEY"
```
🔴 Permanent. The number returns to general carrier inventory and anyone can buy
it. If you only want to stop paying, note that not paying suspends rather than
releases, and reverses itself.
## The 140 and 160 series
`140` (telemarketing) and `160` (transactional, BFSI) exist in the regulations
and in `NUMBER_SERIES`, and both require an approved DLT Principal Entity ID.
**Neither is purchasable through a carrier API today** — search and purchase only
ever offer regular numbers. We would rather say so than show you a Buy button
that cannot work.
---
# Quickstart
Source: https://docs.usetone.ai/quickstart
> Build a voice agent, give it a phone number, and place a call with a known outcome — in five minutes, for free.
By the end of this page you'll have placed a call through the Tone API and read back its outcome.
It costs nothing, no phone rings, and you don't need KYC, a DLT registration, or a wallet balance —
everything here runs in [test mode](/test-mode).
Everything you build here works unchanged in production. Going live is a key swap.
## 1. Get a test key
In the dashboard, open **Developer → API keys** and create a key with environment **test** and
scope **write**. Copy it — it's shown once.
```bash
export TONE_API=https://apibeta.usetone.ai
export TONE_KEY=tone_test_...
```
Check it works:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/limits"
```
You should get your request budget and concurrency quotas back. If you get a `401`, the key is
wrong or revoked; if you get `insufficient_scope` later on, it was minted `read`-only.
## 2. Create an agent
An agent is the thing that talks: a prompt, a voice, and a language.
```bash
curl "$TONE_API/v1/agents" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"name": "Order confirmation",
"purpose": "transactional",
"systemPrompt": "You call customers to confirm a cash-on-delivery order. Confirm the order, ask which day suits them for delivery, then thank them and end the call. Be brief and polite.",
"voice": {
"ttsModel": "bulbul:v3",
"ttsVoice": "simran",
"sttModel": "saaras:v3",
"languages": ["hi-IN", "en-IN"]
}
}'
```
```json
{ "data": { "id": "a4f21c8e-...", "name": "Order confirmation", "status": "draft", "...": "..." } }
```
Save the `id` as `$AGENT`.
Two fields deserve a moment:
**`purpose`** is your call's regulatory classification — `transactional`, `service`,
`promotional`, or `collections` — and it is not cosmetic. India's TCCCPR rules restrict when
promotional and collections calls may be placed, and Tone's compliance gate enforces that on every
dial using this field. There is deliberately no way to override it per call; if there were, the
audit trail would be worthless. Classify honestly.
**`voice.ttsVoice` must be valid for `ttsModel`.** Voices aren't portable across model versions:
`simran` is a `bulbul:v3` voice and is rejected on `bulbul:v2`. A bad pairing is a `400` at write
time rather than a failure mid-call. Browse what's available:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/catalog/voice"
```
### Publish it
Agents are created as drafts:
```bash
curl "$TONE_API/v1/agents/$AGENT" \
-X PATCH \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"status":"live"}'
```
You can dial with a draft agent — outbound calls run whatever you point them at, which is what
makes iterating quick. Publishing matters for **inbound**: a number whose agent is still a draft
refuses incoming calls, on the grounds that a half-written prompt shouldn't answer a real customer.
## 3. Get a phone number
In test mode, numbers are free and allocated on request:
```bash
curl "$TONE_API/v1/numbers" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"series":"regular","agentId":"'"$AGENT"'"}'
```
```json
{ "data": { "id": "b91e...", "e164": "+918041000123", "status": "active", "environment": "test", "agentId": "a4f21c8e-...", "...": "..." } }
```
Save the `id` as `$NUMBER`. Binding `agentId` here is what makes this agent answer calls *to* this
number; for placing calls, you'll name the agent explicitly.
In production you'd search real carrier inventory and buy — see [Going live](#7-going-live).
## 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 '{
"agentId": "'"$AGENT"'",
"numberId": "'"$NUMBER"'",
"toE164": "+915555000001",
"variables": { "order_id": "SO-4417", "customer_name": "Priya" }
}'
```
```json
{ "data": { "id": "3b7e...", "status": "queued", "direction": "outbound", "environment": "test", "...": "..." } }
```
Three things happened in that request:
- **`+915555000001` is a [magic number](/test-mode#magic-numbers)** — in test mode it always
answers, for 45 seconds, with a canned summary. `...0002` is always busy, `...0003` never
answers. Deterministic outcomes are what let you write real tests.
- **`Idempotency-Key`** makes the request safe to retry. If the connection drops and you send it
again with the same key, you get the original call back rather than dialling twice.
[More on idempotency](/idempotency).
- **`variables`** fill the `{{placeholders}}` in your agent's prompt, per call. They're stored on
the call record and returned by reads.
### If you get a `403`
```json
{ "error": { "type": "compliance", "code": "blocked_dnd", "message": "That number is on your Do-Not-Call list." } }
```
That's the compliance gate, and it runs in test mode exactly as it does live. Handle it as a normal
outcome, not an exception: some fraction of any real recipient list is going to come back blocked.
See [the compliance guide](/quickstart-compliance).
## 5. Read the outcome
The sandbox rings, then settles, within a few seconds:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/calls/3b7e..."
```
```json
{
"data": {
"id": "3b7e...",
"status": "ended",
"disposition": "answered",
"durationSeconds": 45,
"billedPaise": 0,
"summary": "Sandbox call: the customer confirmed the order and asked for delivery on Thursday.",
"outputs": { "confirmed": true, "preferred_day": "thursday", "sandbox": true },
"variables": { "order_id": "SO-4417", "customer_name": "Priya" }
}
}
```
`status` moves `queued` → `in_progress` → `ended`. Only when it reaches `ended` is `disposition`
meaningful — `answered`, `no_answer`, `busy`, `voicemail`, `failed`, or `unknown`. Treat that list
as open: new dispositions can appear without being a breaking change.
`outputs` is structured data extracted from the conversation, shaped by the output variables you
declare on the agent. That's usually the field your application actually cares about — the
transcript is for humans, `outputs` is for your database.
## 6. Stop polling — use webhooks
Polling works for a quickstart. For anything real, subscribe:
```bash
curl "$TONE_API/v1/integrations/webhooks" \
-X POST \
-H "Authorization: Bearer $TONE_ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/tone",
"events": ["call.completed", "call.failed"]
}'
```
The response contains a `whsec_...` signing secret, shown **once**. Every delivery is signed with
it, and verifying that signature is not optional — see [Webhooks](/webhooks) for the full
catalog, the verification snippet, and the retry behaviour.
Registering an endpoint needs an `admin` key, because the URL decides where your call data flows.
## 7. Going live
```bash
export TONE_KEY=tone_live_...
```
That is genuinely the code change. What has to be true around it:
1. **Complete KYC and DLT registration** in the dashboard. Live keys unlock at verification, and
the compliance gate blocks promotional traffic without an approved DLT PE ID.
2. **Fund your wallet.** Live calls debit per minute; live numbers charge a setup fee and a monthly
rental. Subscribe to `wallet.balance.low` so you hear about it before a campaign pauses itself.
3. **Buy a real number** with an `admin` key. Search inventory, then purchase — and note the
mandatory `Idempotency-Key`, because a duplicate purchase is a second monthly rental:
```bash
curl -H "Authorization: Bearer $TONE_LIVE_ADMIN_KEY" \
"$TONE_API/v1/numbers/available?number_type=mobile®ion=KA"
curl "$TONE_API/v1/numbers/purchase" \
-X POST \
-H "Authorization: Bearer $TONE_LIVE_ADMIN_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'content-type: application/json' \
-d '{"e164":"+918041234567","label":"orders line"}'
```
4. **Register a live webhook endpoint.** Endpoints and their secrets are per-endpoint, not shared
between environments — point test and live at different URLs so a sandbox event can never be
mistaken for a real one.
5. **Re-check your error handling** against the [error catalog](/errors), especially
`insufficient_funds`, `concurrent_call_limit_reached`, and the `blocked_*` family. In test mode
you had no wallet and no real capacity limits; in production both bite.
## Where to go next
- **[Calling many people](/campaigns)** — create a campaign, add recipients, launch it.
Campaigns handle pacing, concurrency, retries, and the calling-window rules for you.
- **[Bring your own voice stack](/quickstart-byo)** — keep Tone's numbers and compliance, run
your own AI.
- **[Compliance as an API](/quickstart-compliance)** — verdicts, consent, and suppression, with
or without placing calls through us.
- **Knowledge bases** — upload documents your agent can answer from, and attach them with
`knowledgeBaseIds` on the agent.
---
# 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=` 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.
---
# Compliance as an API
Source: https://docs.usetone.ai/quickstart-compliance
> Ask "may I call this number?" and get an auditable answer — with or without placing the call through Tone.
You already have a dialer, a carrier, and a voice stack. What you don't have is a defensible answer
to "why did you call this person?" when a TRAI complaint lands.
This tier is that answer as an API: a verdict endpoint, a consent ledger, a suppression list, and an
evidence pack — all writing the **same audit rows** a call placed through Tone writes. You keep your
own infrastructure. Nothing here requires you to buy a number from us.
## 1. Declare your sender classification
The calling-window rules depend on what kind of sender you are, so say so once:
> **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.
This is a **dashboard action, not an API-key one** — an API key gets a `403`. In the dashboard it's
the **Sender classification** field under **Telephony → Compliance**. Loosening the rules
that govern every subsequent call should be a deliberate act by an accountable human, and the change
is itself recorded as a `profile_change` audit row. The same applies to switching DNC enforcement
off.
## 2. Ask before you dial
```bash
curl "$TONE_API/v1/compliance/check" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"e164":"+919876543210"}'
```
```json
{
"data": {
"e164": "+919876543210",
"purpose": "promotional",
"allowed": false,
"blockedBy": "dnc",
"checks": [
{
"id": "8814",
"checkType": "dnc",
"outcome": "block",
"source": "internal",
"reason": "Opted out on 2026-06-02",
"enforced": true,
"detail": { "...": "..." },
"createdAt": "2026-08-24T09:41:07.812Z"
},
{ "id": "8815", "checkType": "time_window", "outcome": "pass", "...": "..." }
]
}
}
```
`200` either way — a refusal is a verdict, not an error. Branch on `allowed`.
Three things make this useful rather than decorative:
- **`checks` is the evidence, not a summary.** Each entry is a row we persisted, with an `id` you
can cite later. `outcome` is `pass`, `warn`, or `block`, and only `block` sets `allowed: false` —
a carrier-DND `warn` is advisory and does not stop a call.
- **The verdict is self-describing.** It records the `purpose` it evaluated, so a verdict read back
in six months still explains itself. Pass `purpose` explicitly to check a specific campaign's
classification; omit it to use your declared default.
- **`blockedBy` names the check that refused**, so you can act on it — `dnc` needs a suppression
removal, `consent` needs a consent record, `time_window` needs a different hour of the day.
### Pre-flighting a list
```bash
curl "$TONE_API/v1/compliance/check/batch" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"e164s":["+919876543210","+919812345678"],"purpose":"promotional"}'
```
Up to 100 numbers per request. Each one runs the full gate and writes its own audit rows — this is
the same work, batched, not a cheaper approximation.
## 3. Record consent
Consent legally beats preference in India: a recipient on the DND register with valid consent on
file is callable, and the record proving it is what the gate reads.
```bash
curl "$TONE_API/v1/consent" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"e164": "+919876543210",
"purpose": "promotional",
"kind": "explicit",
"source": "otp",
"evidenceRef": "form-7741",
"scope": "Offers and product updates by voice call",
"capturedAt": "2026-08-20T11:02:00Z"
}'
```
- **`kind`** is `explicit` (they agreed) or `inferred` (an existing relationship implies it).
- **`source`** is `api`, `web_form`, `ivr`, `otp`, `dca`, or `import`, weakest to strongest as
evidence. `dca` mirrors the operator DLT's Digital Consent Acquisition facility.
- **`capturedAt` is when *they* consented**, not when you told us. Send it when importing history —
otherwise every imported consent dates to the day of the import, which makes the expiry clock
meaningless.
- **`expiresAt`** is optional for explicit consent, where policy fills it in: TCCCPR 2025 caps
explicit transactional consent at **seven days**. It is **required** for `inferred` consent —
that lasts exactly as long as the relationship, and only you know when that ends.
Consent is scoped to a purpose. A consent for `service` does not authorize a `promotional` call, and
the gate will tell you so.
The ledger is append-only. Revocation is a stamp, not a delete:
```bash
curl -X POST "$TONE_API/v1/consent/CONSENT_ID/revoke" -H "Authorization: Bearer $TONE_KEY"
```
## 4. Close the loop on what happened
A verdict you never acted on proves nothing. Report the outcome:
```bash
curl "$TONE_API/v1/compliance/call-outcomes" \
-X POST \
-H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{
"e164": "+919876543210",
"outcome": "opt_out",
"checkId": "8814",
"callReference": "your-call-id-4417",
"notes": "Asked to be removed"
}'
```
`outcome` is `connected`, `no_answer`, `busy`, `failed`, `opt_out`, `complaint`, or `wrong_number`.
`checkId` ties it back to the verdict you acted on; `callReference` is your own id, for your
reconciliation.
Two outcomes do real work rather than just recording:
- **`opt_out`** revokes every consent for that number and writes a 90-day suppression, in one
transaction. Your next check on that number comes back `allowed: false`.
- **`complaint`** suppresses permanently.
This is the same function Tone's own calls use when a recipient opts out mid-conversation, so a
mixed estate — some calls through Tone, some through your own dialer — maintains one suppression
list, not two that disagree.
## 5. Manage suppression directly
```bash
# Add one
curl "$TONE_API/v1/dnc" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"e164":"+919876543210","source":"api","reason":"Emailed us to be removed"}'
# Import a list — up to 1000 per request
curl "$TONE_API/v1/dnc/bulk" -X POST \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"entries":[{"e164":"+919876543210","source":"csv"},{"e164":"+919812345678","source":"csv"}]}'
# Read it back
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/dnc?limit=100"
```
This list is the only thing the gate hard-blocks on, which makes it the one to get right. Bring
your existing suppression list with you on day one.
## 6. Produce the evidence
When a complaint arrives:
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/compliance/evidence?e164=%2B919876543210"
```
You get everything held about that number: every check as it was recorded at the time, every
consent including revocations, the suppression entry, and the outcomes you reported — raw evidence,
not a summary of it. `truncated` is honest if the window held more than the cap.
For a rolling view of decisions:
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/compliance/checks?check_type=dnc&outcome=block&limit=50"
```
Cursor-paginated like every list endpoint.
## What this tier does and doesn't do
**It does:** run the same gate Tone's own dials run, persist every decision, keep an auditable
consent ledger, maintain your suppression list, and hand you an evidence pack per number.
**It doesn't stop your dialer.** The verdict is advisory to *you* — we have no way to prevent a call
you place on your own carrier. What we can prove is that you asked, what you were told, and what you
did next. If you want refusals actually enforced at dial time, place the calls through Tone
(see the [quickstart](/quickstart)) or rent a number and
[bring your own voice stack](/quickstart-byo).
**One case has no pre-dial hook at all:** if you dial over a SIP trunk, your platform reaches the
carrier directly and nothing of ours is in the path. The verdict API is your pre-flight, and the
carrier's CDR — which we ingest and price — is the evidence.
## Billing
Verdicts are metered per number checked, and charged **before** the gate runs — an unpaid verdict
never leaves audit rows that look like a paid one. Checks made with a `tone_test_` key are free, so
build and test your integration at no cost.
---
# Recordings and transcripts
Source: https://docs.usetone.ai/recordings
> What a finished call leaves behind, and how to read it.
Every call produces a detail record. Answered calls also produce a transcript
and, unless you have turned it off, an audio recording.
```bash
curl "$TONE_API/v1/calls/$CALL" -H "Authorization: Bearer $TONE_KEY"
curl "$TONE_API/v1/calls/$CALL/transcript" -H "Authorization: Bearer $TONE_KEY"
curl "$TONE_API/v1/calls/$CALL/recording" -H "Authorization: Bearer $TONE_KEY" -o call.wav
```
## The call record
Beyond the obvious — outcome, duration, what was billed — a call carries:
| Field | |
|---|---|
| `summary` | A short account of what happened, written after the call |
| `outputs` | The output variables you declared on the agent, extracted from the transcript |
| `evaluations` | Your evaluation criteria, each with a verdict and a rationale |
| `variables` | The per-call values this call was given |
| `agentVersion` | Which version of the agent ran it |
| `endReason` | How it ended, in more detail than `disposition` |
## Recordings
The recording is a **stereo WAV**: the caller on the left channel, the agent on
the right. Two channels rather than a mix, because a mixed recording cannot
answer "who was talking?" — which is the question you have when reviewing a call
that went wrong.
It streams as `audio/wav` and is not wrapped in the usual envelope. Expect
several megabytes.
Recording is controlled per number:
```bash
curl "$TONE_API/v1/numbers/$NUMBER" -X PATCH \
-H "Authorization: Bearer $TONE_KEY" -H 'content-type: application/json' \
-d '{"recordCalls": false}'
```
⚠️ `recordCalls` can only turn recording **off**. It is combined with the
platform-level setting, so it can never enable recording where the deployment has
it disabled. **Telling the caller they are being recorded is your obligation, not
ours** — the opening line is the usual place.
## Absences are meaningful
A call with no transcript is a call with no speech: unanswered, busy, or a
sandbox call where nobody spoke. A call with no recording either was never
connected or ran on a number with recording off. `hasRecording` and
`hasTranscript` on the call record tell you before you fetch.
---
# Support, status and security
Source: https://docs.usetone.ai/support
> How to reach us, what to include, and where to report a vulnerability.
## Before you write
Two things make almost every question answerable in one reply:
**The `request_id`.** Every error carries one. It identifies the exact request in
our logs, which turns a support thread into a lookup.
```json
{ "error": { "code": "provider_unavailable", "request_id": "req_8f14a2c9..." } }
```
**The call id**, for anything about a specific call. With it we can read the
compliance checks, the disposition and the timing; without it we are guessing
from a description of what you heard.
## Things you can answer yourself, faster
| Question | Where |
|---|---|
| "Why was this call blocked?" | `GET /v1/compliance/evidence?e164=…` — every check, with reasons |
| "Did my webhook get delivered?" | `GET /v1/integrations/webhooks/deliveries` |
| "What exactly did you send me?" | `GET /v1/events/{id}` — 30-day retention |
| "Why did this call cost that?" | `GET /v1/wallet/transactions` — one row per call |
| "Am I being rate limited?" | `GET /v1/limits`, and the `RateLimit` headers on every response |
## Status
Incidents and planned maintenance are posted on the status page. If calls are
failing and the status page is green, tell us — a quiet incident is worse than a
loud one.
### Checking the API yourself
Two unauthenticated endpoints, for your own monitoring. Neither counts against
your rate limit and neither needs a key.
```bash
curl "$TONE_API/health" # liveness — the process is up
curl "$TONE_API/health/ready" # readiness — it can also reach its database
```
`GET /health` answers `200` whenever the API is running. `GET /health/ready`
additionally checks the database and answers `503` when it cannot reach it —
which is the one to point an uptime monitor at, because a process that is up but
cannot read its own data will fail your requests while liveness still says
everything is fine.
Neither is a substitute for the status page: they tell you about the API, not
about the carrier or the speech vendors your calls also depend on.
## Talking to sales
Volume pricing, a negotiated per-minute rate, contracts and anything about
onboarding a large number of lines go through **hello@usetone.ai**, or the form
on the marketing site (`POST /v1/contact-sales`, which is what that form posts —
it takes no credential and is not something you need to integrate against).
## Security
Report a vulnerability to **hello@usetone.ai**. Please include enough detail
to reproduce it, and give us a chance to fix it before disclosing.
Things worth knowing when assessing us:
- **Keys are shown once and stored hashed.** A leaked key is revoked from the
dashboard with `DELETE /v1/api-keys/{id}`, effective on the next request.
Rotation (`POST /v1/api-keys/{id}/roll`) mints a replacement while the old one
keeps working for a window you choose, down to `now`. Both take a dashboard
session rather than a key — a key can neither mint nor revoke credentials.
- **Scopes only ever narrow.** A key cannot be widened after minting, and a key
cannot mint another key.
- **Tenant isolation is enforced in the database**, not in application code, and
an unscoped read returns nothing rather than someone else's data.
- **Secrets are write-only.** There is no read-back path for a stored
credential, which is why tool and endpoint credentials are referenced by id
rather than written inline.
- **Webhook payloads are signed** over the raw body — see
[Webhooks](/webhooks).
## What we will not do
We will not tell you a compliance question is legally settled. Tone runs the
TRAI checks and keeps the evidence that you ran them; TCCCPR liability stays
with the sender. On anything that turns on your specific obligations, we will
tell you what the product does and recommend you ask your own counsel.
---
# Test mode and magic numbers
Source: https://docs.usetone.ai/test-mode
> A full sandbox with deterministic call outcomes, so your integration has real tests instead of hopeful ones.
Every Tone account has two parallel universes, chosen by which key you send:
| | `tone_test_` | `tone_live_` |
|---|---|---|
| Carrier | Simulated | Real |
| Phones | Nothing rings | A real person's phone rings |
| Money | Priced, never charged | Charged to your wallet |
| Call lifecycle | Complete — states, dispositions, webhooks, CDR rows | Complete |
| Compliance gate | Runs, with the same rules | Runs |
| Available | From signup, before KYC | After verification |
Test mode isn't a stub. Calls move through the same states, settle with the same dispositions,
emit the same webhooks with the same signatures, and land in the same call log with the same
fields. **Going live is a key swap and nothing else.**
That includes the compliance gate: a call your live integration would be blocked from placing is
blocked in test too, with the same `403` and the same audit trail. Discovering a compliance
problem in your sandbox is the point.
## Getting a sandbox number for free
Test numbers don't have to be bought. `POST /v1/numbers` allocates one from the sandbox pool —
no carrier, no wallet, `write` scope:
```bash
curl "$TONE_API/v1/numbers" \
-X POST -H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"series":"regular","agentId":"YOUR_AGENT_ID"}'
```
`POST /v1/numbers/purchase` — which names a specific `e164` and goes to a carrier — also works with
a test key against simulated inventory, if what you're testing *is* the purchase flow. For
everything else, assign.
## Magic numbers
Dial one of these from a test-mode call and the outcome is fixed. Same number, same disposition,
same webhook, every time — which is what makes an integration testable in CI instead of
"we called ourselves and it seemed fine".
| Number | What happens |
|---|---|
| `+91 55550 00001` | **Answered.** 45-second call, with a canned `summary` and structured `outputs` your assertions can match on |
| `+91 55550 00002` | **Busy** |
| `+91 55550 00003` | **No answer** |
| `+91 55550 00004` | **Voicemail** |
| `+91 55550 00005` | **Answered**, and the number reads as listed on the carrier DND registry — a compliance *warning*, not a block |
| `+91 55550 00006` | **Answered**, and the callee opts out mid-call. Your next dial to this number is blocked |
Any other number in test mode simply answers. Write them without spaces:
`"toE164": "+915555000001"`.
Outcomes take a few seconds to land — the sandbox rings before it answers, so your
`call.initiated` webhook reliably precedes your `call.completed` one, exactly as it does live.
### Asserting on a known outcome
```bash
curl "$TONE_API/v1/calls" \
-X POST -H "Authorization: Bearer $TONE_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H 'content-type: application/json' \
-d '{"agentId":"'"$AGENT"'","numberId":"'"$NUMBER"'","toE164":"+915555000001"}'
```
Poll `GET /v1/calls/{id}` (or wait for the webhook) until `status` is `ended`:
```json
{
"data": {
"id": "3b7e...",
"status": "ended",
"disposition": "answered",
"durationSeconds": 45,
"summary": "Sandbox call: the customer confirmed the order and asked for delivery on Thursday.",
"outputs": { "confirmed": true, "preferred_day": "thursday", "sandbox": true }
}
}
```
`outputs.confirmed === true` is a stable assertion. So is `disposition === "busy"` for `...0002`.
### The two compliance magics
These are the ones worth understanding, because they behave the way the real rules behave rather
than the way a mock would.
**`...0005` warns, it does not block.** Carrier DND data is advisory in Tone's gate: it flags a
call, it never refuses one. Ask for a verdict directly and you can see it:
```bash
curl "$TONE_API/v1/compliance/check" \
-X POST -H "Authorization: Bearer $TONE_KEY" \
-H 'content-type: application/json' \
-d '{"e164":"+915555000005"}'
```
```json
{
"data": {
"e164": "+915555000005",
"purpose": "service",
"allowed": true,
"blockedBy": null,
"checks": [ { "checkType": "carrier_dnd", "outcome": "warn", "...": "..." } ]
}
}
```
`allowed: true` with a `warn` is the correct outcome, and your code should treat it as one.
**`...0006` blocks — on the second call.** The first dial answers and the callee opts out, which
revokes their consent and writes a 90-day suppression to your own do-not-call list. Dial it again:
```json
{
"error": {
"type": "compliance",
"code": "blocked_dnd",
"message": "That number is on your Do-Not-Call list.",
"doc_url": "https://docs.usetone.ai/errors#blocked_dnd",
"request_id": "req_8f14a2..."
}
}
```
`403`. A `compliance.check.blocked` webhook fires alongside it, and the suppression is visible at
`GET /v1/dnc`.
Those two dials are the entire opt-out lifecycle — consent revocation, suppression, refusal,
webhook, audit trail — with no fixtures to set up. If your integration handles a `403` from
`POST /v1/calls` correctly, you've tested the thing most likely to bite you in production.
## What differs from live
Being honest about the edges, so nothing surprises you at cutover:
- **Nobody speaks.** The sandbox produces call *outcomes*, not conversations. There's no audio, no
transcript, and the `summary`/`outputs` on `...0001` are canned rather than generated. To hear
your agent actually talk, use the test call in the dashboard, which runs the real voice pipeline
through your browser.
- **Prices are simulated.** A sandbox number quotes a plausible setup and rental, and a sandbox
call is priced — but nothing is debited, and the numbers are not a quote. What makes a sandbox
call free is the environment, nothing else: a test call reads `channel: "pstn"` and reports the
number it dialled in `peer`, exactly as the live call it stands in for does, and it still bills
`billedPaise: 0`.
- **Sandbox inventory is not carrier inventory.** The specific `e164` values available to search
and buy in test are made up. Don't build anything that expects a particular one to exist.
- **Calls and numbers are environment-scoped; agents and knowledge bases are not.** A `tone_test_`
key naming a live number — or the reverse — fails with `environment_mismatch`, and a test key
structurally cannot read live calls. But an agent is one object in both universes: the agent you
tuned against magic numbers is the same agent, with the same prompt and voice, that answers a
real phone the moment you swap the key. That's deliberate — an agent you tested and an agent you
shipped should not be two objects that can drift apart. The same is true of knowledge bases.
---
# Tone Media Streams
Source: https://docs.usetone.ai/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
```jsonc
{"event":"connected","protocol":"Call","version":"1.0.0","sequenceNumber":"1"}
{"event":"start","sequenceNumber":"2","streamSid":"MZ…",
"start":{"streamSid":"MZ…","callSid":"","tracks":["inbound"],
"mediaFormat":{"encoding":"audio/l16","sampleRate":8000,"channels":1},
"customParameters":{"callId":"","direction":"outbound","from":"+91…","to":"+91…", "…":"…"}}}
{"event":"media","sequenceNumber":"3","streamSid":"MZ…",
"media":{"track":"inbound","chunk":"1","timestamp":"20","payload":""}}
{"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":"","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
```jsonc
{"event":"media","streamSid":"MZ…","media":{"payload":""}}
{"event":"mark","streamSid":"MZ…","mark":{"name":"turn-3"}} // echoed back when played
{"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.
3. **Hang up by closing the socket.** Tone closes the carrier leg, bills the
call, and sends your webhook.
### Audio
| Setting | Values | Notes |
|---|---|---|
| `format` | `linear16` (default) \| `mulaw8k` | μ-law is the Twilio-compat mode |
| `sampleRate` | `8000` (default) \| `16000` | linear16 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 `403`.)
2. Put the credential your endpoint expects into Tone's secret store, in the
**dashboard** under Secrets — the same store tool credentials use. Tone sends
it as the `Authorization` header value, verbatim. (`POST /v1/secrets` is
session-only too: minting credentials is deliberately closed to API keys.)
3. Switch the number:
```http
PATCH /v1/numbers/{id}
{
"routingMode": "byo_ws",
"mediaEndpoint": {
"type": "static",
"url": "wss://bot.example.com/media",
"format": "linear16",
"sampleRate": 8000,
"authSecretId": "",
"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.
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.
### Placing a call
```http
POST /v1/calls
{ "numberId": "", "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.
## 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:
```http
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:
```json
{ "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 is post-hoc, not pre-dial.** Your platform dials the
carrier directly, 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.50/min, with MOS/jitter/packet-loss in
the call record — and fires `call.completed`. The evidence pack for a number
includes those calls.
## Billing
A BYO minute is **₹2.50** — 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.
## Testing without a phone
Point the fake caller at your server:
```bash
cd voice && pnpm test:byo -- wss://bot.example.com/media --wav hello.wav --format linear16 --rate 8000
```
It performs the handshake, streams the WAV at real time as a caller would,
prints every frame you send, echoes your `mark`s, and writes what a caller
would have heard to `byo-reply.wav`. With no `--wav` it streams silence —
enough to prove the handshake and your `mark`/`clear` plumbing.
For a full carrier loop locally, run the fake carrier (`skrull/`) with
`BYO_ALLOW_PRIVATE_TARGETS=true` on the voice service and a BYO number on a
`tone_test_` key: the dial, the bridge, billing and the webhook all execute
against your endpoint on localhost with no money and no phone ringing.
## Refusals you may see in the call log
| `endReason` | Meaning |
|---|---|
| `refused_no_endpoint` | Number is BYO but has no media endpoint configured |
| `refused_endpoint_unreachable` | Connect (or webhook fetch) failed or timed out |
| `refused_endpoint_forbidden` | Endpoint resolved to a private address or was not `wss://` |
| `refused_media_format` | Carrier rate and your configured rate cannot be bridged |
| `max_duration` | The call hit the platform ceiling (60 min) |
Each is a `failed` disposition, reported — never a silent drop.
---
# Tools
Source: https://docs.usetone.ai/tools
> Let an agent call your API mid-conversation — check an order, book a slot, look up a balance.
A tool is an HTTP endpoint of yours that the agent may call while it is talking.
Tools live on the agent (`tools`, up to 10) rather than as their own resource.
```json
{
"tools": [{
"name": "lookup_order",
"description": "Look up an order by its id. Use this when the caller asks about an order they have already placed.",
"parameters": [
{ "name": "order_id", "type": "string", "description": "The order id, e.g. AC-4417", "required": true, "in": "path", "path": "orderId" }
],
"speech": { "en-IN": "Let me pull that up.", "hi-IN": "मैं देखता हूँ।" },
"http": {
"url": "https://api.example.com/orders/{orderId}",
"method": "GET",
"auth": { "type": "bearer", "secretId": "..." },
"timeoutMs": 4000
}
}]
}
```
## The description is the contract
`description` is not documentation — it is what the model uses to decide whether
to call the tool at all. Write it as a trigger condition ("use this when the
caller asks about an order they have already placed"), not as a summary ("order
lookup endpoint"). The same goes for each parameter.
## Speech lines matter more than they look
An HTTP call takes time, and silence on a phone line reads as a dropped call.
`speech` is what the agent says while it waits. Give one per language the agent
speaks; without it the caller hears nothing while your API thinks.
Keep `timeoutMs` honest — 1000–30000 is allowed, but anything past a few seconds
is a long silence even with a filler line.
## Credentials are referenced, never inline
🔴 A tool may not carry a credential in its definition. `auth.secretId` points at
an entry in your secret store, and an `authorization`-style header written inline
is rejected outright.
Secrets are created in the dashboard under **Developer → Secrets** — the store is
write-only, with no read-back path, and an API key cannot create one. That is
deliberate: a credential that can be read back is a credential that leaks through
whatever can read it.
URLs are checked when you save the agent and checked again at execution time
after DNS resolution, so a hostname that resolves into private address space is
refused both when it is written and when it is used.
## Mock it before you build it
```json
{ "mock": { "enabled": true, "response": { "status": "shipped", "eta": "Thursday" } } }
```
With `mock.enabled`, the tool returns your canned response instead of calling
anything. Design the conversation first, build the endpoint second.
## Built-in tools
`end_call` is available and is how an agent hangs up deliberately. `transfer_call`
and `press_digit` are reserved names — they are **not built**, and an agent
declaring them will not get the behaviour you expect.
---
# Versioning and breaking changes
Source: https://docs.usetone.ai/versioning
> /v1 is a promise, not a version number. What that commits us to, what it asks of you, and how it is enforced.
There is no plan for a `/v2`.
That is not optimism — it is a constraint we accepted, and it shapes how the API
is designed. Anything we cannot do additively, we do not do.
## What we promise
**Additive only.** These can appear at any time and are **not** breaking:
- A new endpoint
- A new **optional** request field
- A new response field
- A new value in any enum-shaped field
- A new webhook event type
- A clearer error message
**Never, on `/v1`:**
- Removing or renaming an endpoint, a field, or an error code
- Changing a field's type
- Making an optional request field required
- Removing a value we have previously published
- Changing an endpoint's success status code
- Requiring a broader scope than before
## What that asks of you
One thing, and it is the whole bargain.
🔴 **Treat every enum as an open set.** A field documenting `answered`,
`no_answer`, `busy`, `voicemail`, `failed`, `unknown` is telling you what exists
today — not what can ever arrive. If your client throws on an unrecognised
value, our compatible change becomes your outage.
```js
// Wrong — turns our additive change into your incident
switch (call.disposition) {
case 'answered': return handleAnswered(call);
case 'busy': return handleBusy(call);
default: throw new Error(`unknown disposition: ${call.disposition}`);
}
// Right
switch (call.disposition) {
case 'answered': return handleAnswered(call);
case 'busy': return handleBusy(call);
default: return handleUnknown(call); // log it, carry on
}
```
The same applies to `error.code`: branch on the codes you handle, and fall back
on `error.type` and the HTTP status for one you have not seen.
## How this is enforced
Not by review. `backend/openapi.public.json` is committed, and every change to
the API is diffed against it:
```bash
pnpm --filter backend openapi:check # the spec matches the code
pnpm --filter backend openapi:gate # the change is allowed on /v1
```
The gate classifies every difference and **fails the build** on a removed
operation, a removed or retyped field, a narrowed set of known values, a newly
required field, a moved success status or a widened scope. A breaking change
cannot be merged by someone who did not notice it was breaking.
## Deprecation
When something is superseded we mark it deprecated, keep it working, and say
what to use instead. We do not set removal dates for `/v1`, because a removal
date is a breaking change with a delay on it.
A voice retired by our speech provider is the clearest case: it disappears from
the pickers, keeps working for every agent already using it, and is flagged
`deprecated` in the catalog. A vendor's documentation edit must not break a
tenant that changed nothing.
## Date-pinned versions
Some APIs let you pin a version by date. We have deliberately not built that.
A pinning mechanism makes breaking changes *possible*, and a thing that is
possible gets justified. The constraint is more valuable than the escape hatch —
so if we ever need one, that will be a considered decision with a reason, not a
header that was already there.
---
# Voices and languages
Source: https://docs.usetone.ai/voices
> Twelve Indian languages, and the (model, voice) pairing rule that catches most first-time mistakes.
Read the current options rather than hardcoding them:
```bash
curl "$TONE_API/v1/catalog/voice" -H "Authorization: Bearer $TONE_KEY"
```
🔴 **This catalog is fetched from the speech provider, not hardcoded by us.** It
changes without a Tone deploy, which is why every list in it is an *open set*.
A voice that is retired is marked `deprecated` and hidden from pickers, but keeps
working for agents already using it — a vendor's documentation edit must not
break a tenant that changed nothing.
## The pairing rule
**A voice belongs to a specific model version.** The constraint is the
`(ttsModel, ttsVoice)` pair, not the voice name:
```json
{ "ttsModel": "bulbul:v3", "ttsVoice": "simran" } // ok
{ "ttsModel": "bulbul:v2", "ttsVoice": "simran" } // 400 — simran is a v3 voice
```
Tone rejects a bad pair when you save the agent, rather than letting you find out
mid-call. Each speaker in the catalog names the model it belongs to; use that
rather than assuming a name carries across versions.
Model-specific tuning is the same trap. `bulbul:v2` takes `pace`, `pitch` and
`loudness`; `v3` dropped the last two and added `temperature`.
## Languages
**Transcription understands more languages than speech can speak.** With
auto-detect on, a caller speaking one of the wider set is detected correctly —
but replying in a language the speech model cannot produce would simply fail.
So a detected language is clamped to the agent's own `languages` list. Set it to
the languages you actually want to handle:
```json
{ "languages": ["hi-IN", "en-IN"] }
```
An **empty list means "no opinion"** — adopt whatever was detected. That is
rarely what you want on a production agent.
## Code-switching
Indian callers switch language mid-sentence constantly, and the stack is built
for it: detection runs per utterance, not once at the start of the call. An
agent with `["hi-IN", "en-IN"]` will follow a caller who opens in English and
answers in Hindi.
---
# Webhooks and events
Source: https://docs.usetone.ai/webhooks
> The event catalog, how to verify a signature, what retries look like, and how to replay what you missed.
Calls take minutes and campaigns take hours. Webhooks are how you find out what happened without
polling for it.
## Registering an endpoint
```bash
curl "$TONE_API/v1/integrations/webhooks" \
-X POST \
-H "Authorization: Bearer $TONE_ADMIN_KEY" \
-H 'content-type: application/json' \
-d '{
"url": "https://your-app.example.com/hooks/tone",
"events": ["call.completed", "call.failed", "campaign.completed"]
}'
```
```json
{
"data": {
"id": "d81c...",
"url": "https://your-app.example.com/hooks/tone",
"events": ["call.completed", "call.failed", "campaign.completed"],
"secret": "whsec_8f2a...",
"...": "..."
}
}
```
`secret` appears **once**, in this response. Store it. If you lose it you can read it back at
`GET /v1/integrations/webhooks/{id}/secret` with an `admin` key, or roll it — but don't build on
being able to fetch it casually.
Managing endpoints requires `admin` scope: the URL decides where your call data goes.
Subscribe only to what you handle. Every event you're subscribed to is an HTTP request to your
server, and an endpoint that 500s on events it doesn't care about will eventually be switched off.
## The event catalog
| Event | Fires when | `data` contains |
|---|---|---|
| `call.initiated` | A dial was accepted and the call row exists (outbound and inbound) | `call` |
| `call.answered` | The far end connected to the media path | `call` |
| `call.completed` | A call ended — **any** disposition | `call` |
| `call.failed` | A call ended in a non-answer: `failed`, `no_answer`, or `busy` | `call` |
| `campaign.completed` | Every recipient in a campaign has settled | `campaign` |
| `campaign.paused` | A campaign paused — including the automatic wallet-floor pause | `campaign` |
| `compliance.check.blocked` | The pre-dial gate refused a call | `check` |
| `wallet.balance.low` | Your balance crossed your low-balance threshold | `wallet` |
| `number.suspended` | Rent went unpaid past the grace period; the number stopped taking calls | `number` |
| `number.reactivated` | The wallet covered the overdue rent and the number is back | `number` |
| `knowledge.build.completed` | A knowledge base finished building and can answer questions | `knowledgeBase`, `build` |
| `knowledge.build.failed` | A build failed; `build.error` says why | `knowledgeBase`, `build` |
`call.failed` fires **alongside** `call.completed`, not instead of it. Subscribe to `call.completed`
alone if you branch on `disposition` yourself; subscribe to `call.failed` as well if you have a
separate path for non-answers. Subscribing to both means two deliveries for one busy signal — which
is correct, just make sure that's what you intended.
New event types will be added. An unrecognised `type` should be logged and ignored, not thrown on.
## The payload
Every event has the same envelope:
```json
{
"id": "9e41b7c2-...",
"type": "call.completed",
"created_at": "2026-08-24T09:41:07.812Z",
"data": {
"call": {
"id": "3b7e...",
"status": "ended",
"disposition": "answered",
"durationSeconds": 45,
"billedPaise": 480,
"campaignId": null,
"summary": "The customer confirmed the order for Thursday delivery.",
"outputs": { "confirmed": true, "preferred_day": "thursday" },
"...": "..."
}
},
"links": {
"self": "https://apibeta.usetone.ai/v1/calls/3b7e...",
"recording": "https://apibeta.usetone.ai/v1/calls/3b7e.../recording",
"transcript": "https://apibeta.usetone.ai/v1/calls/3b7e.../transcript"
}
}
```
- **`id` is the event id**, repeated in the `x-tone-event-id` header. Use it as your deduplication
key — see [Handling duplicates](#handling-duplicates).
- **`data` is keyed by subject** — `call`, `campaign`, `number`, `check`, `wallet`, or
`knowledgeBase` + `build`. For calls, campaigns, numbers and compliance checks the object is
identical to what that resource's own `GET` returns, so there's one shape to learn and a webhook
can never disagree with the API. The `wallet` payload is a three-field subset of `GET /v1/wallet`
(balance, threshold, currency), and the knowledge events carry the base's identity plus the build
that just finished.
- **`links` are offered only when there's something behind them.** `recording` and `transcript`
appear when they exist, so branch on presence rather than fetching and handling a 404.
Transcripts are not inlined. They're multi-kilobyte and would make us a bad neighbour on your
server; fetch `links.transcript` with your API key if you want one.
## Verifying the signature
**Verify every delivery.** Your endpoint URL is reachable by anyone; the signature is what makes a
request actually from Tone.
Each delivery carries:
```
x-tone-timestamp: 1756029667
x-tone-signature: v1=4f7c8e2a...
x-tone-event: call.completed
x-tone-event-id: 9e41b7c2-...
x-tone-delivery-attempt: 1
```
The signature is `HMAC-SHA256` over the literal string `{timestamp}.{raw body}`, hex-encoded.
```js
import { createHmac, timingSafeEqual } from 'node:crypto';
function verifyTone(rawBody, headers, secret) {
const timestamp = headers['x-tone-timestamp'];
const expected = `v1=${createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')}`;
// The header may carry SEVERAL space-delimited signatures during a secret
// rotation. Accept the message if any one matches.
const provided = String(headers['x-tone-signature']).split(' ');
const ok = provided.some(
(sig) =>
sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig), Buffer.from(expected)),
);
if (!ok) throw new Error('bad signature');
// Reject anything too old for your tolerance. Five minutes is typical.
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) throw new Error('stale');
return JSON.parse(rawBody);
}
```
```python
import hmac, hashlib, time
def verify_tone(raw_body: bytes, headers, secret: str) -> bool:
timestamp = headers["x-tone-timestamp"]
expected = "v1=" + hmac.new(
secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
provided = headers["x-tone-signature"].split(" ")
if not any(hmac.compare_digest(sig, expected) for sig in provided):
return False
return abs(time.time() - int(timestamp)) <= 300
```
Three things people get wrong:
1. **Sign the raw bytes, not a re-serialized object.** If your framework parses JSON before you see
it, `JSON.stringify(req.body)` will not reproduce the original bytes and every signature will
fail. Configure a raw-body reader for this route.
2. **Compare in constant time.** `===` on an HMAC leaks timing.
3. **Split on spaces.** During a secret rotation the header carries one `v1=` signature per active
secret. A verifier that treats the whole header as a single signature will start rejecting
everything the moment you roll — and it'll look like an outage on our side.
The signature deliberately does **not** cover the URL path, so a proxy that rewrites your path
won't break verification.
### Rotating the secret
```bash
curl "$TONE_API/v1/integrations/webhooks/d81c.../roll-secret" \
-X POST -H "Authorization: Bearer $TONE_ADMIN_KEY"
```
You get a new secret and a `previousValidUntil` about 24 hours out. During that window both
secrets sign every delivery — which is exactly why your verifier must handle multiple signatures.
Deploy the new secret at your leisure, and the old one stops after the window.
## Responding
Return any `2xx`. We don't read the body.
Anything else is a failure, and the split is deliberate:
- **`4xx`** (except `408` and `429`) means your server understood and refused. We don't retry —
retrying can't change a `400`.
- **`5xx`, `408`, `429`, timeouts, connection errors** are retried.
**Reply fast, work later.** We give up on a delivery after **10 seconds**. Verify the signature,
enqueue the event, return `200`. Doing your database writes and third-party calls inline is the
most common cause of a webhook endpoint that fails under exactly the load it needs to survive —
a campaign settles calls in clumps.
### Retries
A failed delivery is retried with exponential backoff and jitter — 1, 2, 4, 8, 16, 32, 64, 128
minutes, then every four hours — for up to **14 attempts**, which spans roughly a day. An
overnight outage or a weekend deploy won't lose events.
`x-tone-delivery-attempt` tells you which attempt you're seeing. `1` is the first.
After **20 consecutive failures** across all deliveries, the endpoint is switched off and we email
your organization's owners and admins. Re-enable it from the dashboard once it's fixed. This isn't
punitive: an endpoint that has been dead for a day is a queue that grows forever.
### Handling duplicates
Design for at-least-once. A delivery whose response we never saw — you returned `200` a moment
after our 10-second timeout — gets retried, and you'll process the same event twice.
Deduplicate on `x-tone-event-id` (identical to the payload's `id`), which is stable across every
retry of that delivery. Storing it in a table with a unique constraint is the whole solution.
Also don't assume ordering. `call.completed` can arrive before `call.answered` if the first
attempt at one of them was retried. Make handlers order-independent, or reconcile against
`GET /v1/calls/{id}`, which is always current.
## Replaying what you missed
Your endpoint was down, or you're reconciling. `GET /v1/events` lists everything we sent, newest
first, for the last **30 days**:
```bash
curl -H "Authorization: Bearer $TONE_KEY" \
"$TONE_API/v1/events?type=call.completed&limit=100"
```
```json
{
"data": [
{
"id": "9e41b7c2-...",
"type": "call.completed",
"subjectType": "call",
"subjectId": "3b7e...",
"createdAt": "2026-08-24T09:41:07.812Z",
"delivery": {
"endpointId": "d81c...",
"status": "failed",
"attempts": 14,
"lastStatus": 502,
"deliveredAt": null
}
}
]
}
```
The `id` is the same one we sent in `x-tone-event-id`, so reconciling against your own dedupe table
needs no mapping. `delivery` tells you what actually happened to it — including that it failed 14
times against a 502.
Fetch one with its payload:
```bash
curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/events/9e41b7c2-..."
```
**One caveat worth knowing:** the payload on this endpoint is re-rendered from current state, not
replayed from a stored snapshot. If a transcript landed after the delivery went out, you'll see it
here even though the original body didn't have it. That's usually what you want when
reconciling — it's the truth now — but it means this is not a byte-for-byte record of what we
signed.
An event subscribed by two endpoints appears twice, once per endpoint. Each is a genuinely separate
delivery with its own outcome.