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: trueNo 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
idis 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
{
"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.