Tone docs

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.

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:

EndpointWhy it's mandatory
POST /v1/numbers/purchaseA duplicate purchase is a second number and a second monthly rental — an ongoing liability, not a one-time charge
POST /v1/campaigns/:id/launchA 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

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

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

Was this page helpful?

On this page