Tone docs

Authentication

API keys, the live/test split, scopes, and rotation without downtime.

Every request carries an API key as a bearer token:

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.

Getting a key

Create keys in the dashboard under Developer → API keys, or programmatically:

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:

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

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:

ScopeGrantsTypical holder
readEvery GET. Call logs, agents, numbers, wallet balance, events, compliance history.Analytics, dashboards, monitoring
writeEverything read does, plus creating and updating agents, knowledge bases, campaigns, and placing callsYour application
adminEverything write does, plus spending money and changing where data flows: buying and releasing numbers, provisioning SIP trunks, managing webhook endpoints, reading signing secretsDeployment 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:

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:

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.

Was this page helpful?

On this page