Error catalog
Every error code this API can return, what causes it, and what to do about it.
Every error has the same shape:
{
"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 |
{
"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. Never POST /v1/compliance/check, which answers 200 with the verdict in the body |
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 |
price_changed | POST /v1/numbers/purchase with confirmedTotalPaise |
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 whose request body has a tone_test_ key name a live resource, or the reverse (a resource in the URL path answers 404) |
missing_variables | POST /v1/calls and campaign dials, for an agent with required input variables |
sip_trunking_not_enabled, sip_trunk_not_active, sip_trunk_has_numbers | POST /v1/sip-trunks, DELETE /v1/sip-trunks/{id}, POST /v1/calls on a trunk number |
billing_details_required | Starting a top-up in the dashboard |
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, 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
agentIdon 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.
- Saving an agent whose voice settings the catalog does not allow: a
(ttsModel, ttsVoice)pair that doesn't exist, a tuning option the model does not take, or a language the voice cannot speak. These are judged against the live voice catalog, so they are422, never a400, anddetails.pathnames the field.
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_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 in the request body, or a tone_live_ key naming
a sandbox one — numberId on POST /v1/calls, for example. This is structural — no request field
bridges it. Check which key your process loaded.
A resource of the other environment named in the URL path (/v1/numbers/{id},
/v1/campaigns/{id}, /v1/calls/{id}) answers 404 instead, so a key learns nothing about the
other environment.
missing_variables
422. The agent marks one or more input variables required, and the dial supplied no value for
them and the agent has no default. details.missing lists the names. Pass them in variables, or
give the variable a default on the agent. A campaign excludes the recipient row instead of retrying
it — the same row would be missing the value on every attempt.
Authentication and authorization
unauthenticated
401. No credential, or one we couldn't verify. Check the header form:
Authorization: Bearer tone_live_....
A dashboard-only route answers an API key this way too ("API keys are not accepted here"): minting keys, starting a top-up, managing secrets and integration connections, and setting your sender classification take a dashboard session.
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. A dashboard-only endpoint reached with an API key is not
this code — that is 401 unauthenticated.
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#<code>, 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 suppressed. details.checkType says by which list:
dnc— 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. Nothing overrides it, not even consent. Inspect it atGET /v1/dnc.dnd_register— forpromotionalcalls only, India's DND register, read through a third-party lookup. An active explicit consent for that number and purpose overrides a register listing; record one withPOST /v1/consent.
blocked_opt_out
403. Blocked by the calling window, or an enforcement/profile state. message says which. The
consent check never blocks — it passes or warns.
- 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. On a dial, the a2p check refused the call: it is in block mode, and no auto-dialer
pre-declaration is on file with your access provider. File the declaration, then record its
reference under Compliance → Auto-dialer pre-declaration in the dashboard. The dlt check only
warns.
Outside a dial, asking for a 140- or 160-series number without an approved DLT Principal Entity ID returns this code too. Complete DLT verification in the dashboard first.
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. For POST /v1/calls, a live call needs at least ₹12 in the wallet, and none is
placed while the wallet is on hold for a payment dispute; details carries balancePaise and
minBalancePaise for the balance case. 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.
billing_details_required
422. A top-up was started before your organization saved the billing name, address and state its
GST tax invoice will carry. Every top-up gets a GST invoice, and GST rules require those details on
it, so the payment is not taken until they exist. Add them in the dashboard (the top-up screen asks
the first time, and Settings → Billing & tax edits them later), then top up again.
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.
price_changed
409. You sent confirmedTotalPaise, and the carrier's price for the number has moved since your
customer reviewed it. Nothing was reserved or charged. details carries the current totalPaise,
setupPaise, rentalPaise and renewsAt — show them, and confirm again with the new total.
number_request_pending
409. You asked for a 140- or 160-series number while your organization's earlier request for the
same series is still waiting for review. Refused so a queue of identical requests cannot form —
staff answer the first one, and the Numbers page shows its state meanwhile. A request for the
other series is accepted. Once a request is answered, fulfilled or declined, you can send a new one.
verification_code_invalid
422. The code you entered for a transfer destination does not match the one the verification
call spoke. details.attemptsLeft says how many more tries that code allows; after five wrong
codes it stops working and you need a new call (POST /v1/transfer-destinations/{id}/verify).
verification_code_expired
422. No verification code is waiting for this transfer destination: no call has been placed, the
code expired (it lasts 15 minutes), or five wrong attempts used it up. Place a new verification call
with POST /v1/transfer-destinations/{id}/verify and enter the code it speaks.
call_not_live
409. You asked to transfer a call nobody is talking on: it has ended, it has not been answered
yet, or no agent is holding it — it is bridged to your own endpoint, or it was already handed to a
person. Only a call an agent is on right now can be transferred.
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.
SIP trunking
sip_trunking_not_enabled
403. Your organization cannot create SIP trunks yet. A trunk is your own carrier terminating
on Tone's gateway, which is a contract rather than a toggle: it needs completed business
verification AND a grant from our team. Ask for the grant with
POST /v1/sip-trunks/access-request; GET /v1/sip-trunks/entitlement tells you where the
request stands and which of the two gates is still missing.
sip_trunk_not_active
422. The trunk exists but is not active — either it is still pending_verification (our
team has not yet approved it) or it has been disabled (the response's details.disabledReason
says why). No call can be placed or answered over it until it is active.
It is also returned, with details.reason: "no_outbound_host", when you dial from a number whose
trunk is inbound-only: the trunk has no outbound host, so it cannot place calls. Add one with
PATCH /v1/sip-trunks/{id}. Both cases are refused before any compliance check runs.
sip_trunk_has_numbers
409. You tried to delete a trunk that still carries imported numbers. Release those numbers
first (DELETE /v1/numbers/{id}), then delete the trunk.
sip_trunking_request_pending
409. You called POST /v1/sip-trunks/access-request while one of your requests is already
waiting for review. Refused so a queue of identical rows cannot form for one organization —
staff answer the first one, and GET /v1/sip-trunks/entitlement reports its state meanwhile.
A rejected request is the one you can resubmit; read its reason from that endpoint first,
because resubmitting unchanged wastes both sides.
sip_trunking_already_enabled
409. You asked for SIP trunking on an organization that already has it — there is nothing
left to grant. In practice this means the screen you submitted from is holding a stale
entitlement: re-read GET /v1/sip-trunks/entitlement, and go straight to POST /v1/sip-trunks.
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.