Tone docs

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:

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.

The response envelope

Every successful response is wrapped:

{ "data": { "id": "8f14...", "status": "queued" } }

List endpoints add meta.pagination:

{
  "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.

Casing: bodies are camelCase, query params are snake_case

This split is intentional and consistent:

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

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 ...PaisebilledPaise: 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:

curl -H "Authorization: Bearer $TONE_KEY" "$TONE_API/v1/limits"
{
  "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.jsonThe OpenAPI 3.1 spec — 92 operations, every field described, with example bodies. Import it directly into Postman, Insomnia or an SDK generator.
Postman collectionThe same thing, pre-converted, with the bearer token as a collection variable.
llms.txtAn index of every page, llmstxt.org convention.
llms-full.txtEvery guide, in full, in one file.
Any page + .mdThe Markdown of that page — /errors.md.
/mcpAn 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.

Was this page helpful?

On this page