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