In most systems a duplicate request creates a duplicate row, and a cleanup script fixes it on Monday. In a voice API a duplicate request creates a second phone call, and no script can un-ring a phone. This post covers where duplicate requests actually come from, the exact Idempotency-Key semantics on the Sautikit API (what gets stored, for how long, and how replays are marked), how to derive keys from business identity instead of random UUIDs, and a fetch retry loop that is safe to run as many times as your infrastructure decides to run it.
Here is the nightmare in four steps. Your job runner sends POST /v1/calls to remind a debtor about an overdue invoice. The request reaches Sautikit and a call is created, but the response is slow crossing the network and your HTTP client times out at ten seconds. The runner marks the job failed and retries. The debtor's phone rings twice in one minute, with the same recorded voice asking for the same money.
The cost of the duplicate is not the problem. At Sautikit's per-second KES rates, a stray 40-second reminder is pocket change — see /pricing for current rates. The problem is the person who heard both calls. A duplicate row is invisible; a duplicate collection call is a complaint, an opt-out, and in a regulated vertical like collections, a harassment finding waiting to happen. Voice is the one channel where your retry policy is directly audible to your customers.
None of these are bugs. Every one of them is a system correctly doing its job.
Client timeouts. The request reached the API and did its work; the response did not make it back before your client gave up. You observed a failure, the server observed a success. This ambiguity is fundamental — you cannot know from a timeout whether the call exists. A blind retry resolves the ambiguity by making it true twice.
Queue redelivery. At-least-once queues (SQS, BullMQ, Sidekiq, Cloud Tasks) redeliver any job that is not acknowledged in time — including jobs where the worker finished the API request and then died before acking.
Deploy restarts. A rolling deploy terminates a worker mid-job. The supervisor reschedules the job on the new version, which dutifully sends the request again.
Agent retries. LLM agents treat a failed tool call as something to try again. That is correct agent behavior — which is exactly why the request itself, not the caller, has to be the thing that is safe to repeat.
The common thread: retries are how distributed systems get reliability, so the fix is never "retry less." The fix is making the retried request provably identical to the first one, so the server can refuse to do the work twice.
You opt in by sending one header. Everything below is the documented contract:
Mechanic
Behavior
Header
Idempotency-Key, max 64 characters
Where it applies
Optional on mutating requests (POST/PUT/PATCH/DELETE); a small set of money and credential endpoints require it and return idempotency.key_required without one
Dedup scope
The tuple of workspace, HTTP method, path, and key
What gets stored
The response status and body of the first completed request
What gets cached
2xx–4xx responses only — 5xx is never stored
Replay
The original status and body, with the header X-Idempotency-Replayed: true
TTL
24 hours
Same key, different body
idempotency.conflict — reuse the original body for this key, or send a new key
Five details in that table deserve emphasis.
First-wins. If two duplicates race — say a queue redelivers while the original worker is still in flight — one request executes and the other gets the stored result. You never get two calls out of one key.
Scope is per endpoint, per workspace. Because the dedup record keys on method and path as well as the key string, reusing inv-812 on POST /v1/calls and on a number claim (POST /v1/numbers/{inventory_id}/claim also accepts the header) cannot collide. Prefix your keys by intent anyway — collect:inv-812:... reads better in logs than a bare id.
5xx is never cached. This is deliberate. A 5xx means the request may not have completed, so caching it would pin a transient failure as the permanent answer. Retry after a 5xx and the handler genuinely runs again — keep sending the same key, so that once any attempt does complete with a 2xx–4xx, every later duplicate replays that stored result instead of executing again.
4xx is cached, and that cuts both ways. Suppose your first attempt hits a 402 with code wallet.insufficient_balance because the wallet was empty. You top up over M-Pesa and retry — with the same key, inside the 24-hour TTL, you get the cached 402 back, flagged with X-Idempotency-Replayed: true. That is the correct contract (the original request deterministically failed, and no call was placed), but it surprises people. When you deliberately change the world and try again, mint a new key. Since the cached response proves no call went out, the new key carries no double-dial risk.
The header wins. A deprecated body field idempotency_key still works on POST /v1/calls and the number claim, but the header takes precedence and is the form to use in new code.
The most common way teams defeat their own idempotency layer is this:
// WRONG: a fresh UUID per attempt makes every retry a brand-new request.headers: { "Idempotency-Key": crypto.randomUUID() }
If the UUID is minted at send time, the retry mints a different one, the server sees a different key, and the debtor's phone rings twice anyway. A random key is only safe if you generate it once and persist it with the job before the first send — which is extra state and an extra failure mode. The simpler discipline is to derive the key from whatever makes the action unique in your business:
// RIGHT: identical on every retry of the same logical action.const key = `collect:${invoice.id}:${dueDate}`; // collect:inv-812:2026-10-27
Three rules for choosing the identity:
Encode the action, not the attempt. Ask "what makes this the same call?" For a payment reminder it is the invoice plus the day; for a voice OTP it is the verification session; for an order confirmation it is the order id.
Make the window explicit. The date segment above is your business window: every retry of the 27 October reminder for invoice 812 maps to one key, while tomorrow's follow-up is legitimately a new call. Keep in mind the server forgets a key after its 24-hour TTL — the header protects you within roughly a day. A longer guarantee like "at most one call per invoice per week" belongs in your own scheduler state; the idempotency layer is your last line of defense, not your calendar.
Stay under 64 characters. Compound identities can get long; hash them if needed (collect: + a truncated SHA-256 of the parts) rather than truncating fields and accidentally merging two invoices into one key.
If you place calls from Node, the SDK takes the derived key as a field and sends it as the Idempotency-Key header:
npm install @sautikit/node@0.2.0
import { SautikitClient } from "@sautikit/node";const client = new SautikitClient({ apiKey: process.env.SAUTIKIT_API_KEY });const call = await client.calls.create({ from: "+254709000001", to: invoice.msisdn, // Business identity, not randomness: identical on every retry of this action. idempotencyKey: `collect:${invoice.id}:${invoice.dueDate}`,});
One default is worth understanding before you rely on it. If you omitidempotencyKey, the SDK does not send the request bare — it generates a key and sends it anyway (the same newIdempotencyKey() helper it exports). That makes every single request idempotent in isolation, which sounds like the problem solved and is not: a retry is a second create() call, so it mints a second key, and the server sees two distinct requests. The auto-generated default is the random-UUID mistake from the previous section, relocated into the library. Protection against double-dialling comes from you passing the stable key, every time.
The retry mechanics live at the HTTP layer — status codes and the X-Idempotency-Replayed marker — so the loop below stays on fetch, where all of it is visible. Here is the whole pattern in one function: a stable derived key, timeouts treated as ambiguous, 5xx retried, 4xx surfaced, and the replay marker logged so you can see dedup working in production.
const BASE = "https://api.sautikit.com";const sleep = (ms) => new Promise((r) => setTimeout(r, ms));async function placeReminderCall(invoice) { // Derived from business identity: identical on every retry. const key = `collect:${invoice.id}:${invoice.dueDate}`; for (let attempt = 1; attempt <= 4; attempt++) { let resp; try { resp = await fetch(`${BASE}/v1/calls`, { method: "POST", headers: { "Authorization": `Bearer ${process.env.SAUTIKIT_API_KEY}`, "Content-Type": "application/json", "Idempotency-Key": key, }, body: JSON.stringify({ from: "+254709000001", to: [invoice.msisdn] }), signal: AbortSignal.timeout(10_000), }); } catch { // Timeout or network error: ambiguous — the call may or may not exist. // Retrying is safe ONLY because the key is stable across attempts. await sleep(attempt * 2_000); continue; } if (resp.status >= 500) { // 5xx is never cached server-side; this retry re-runs the request. await sleep(attempt * 2_000); continue; } const replayed = resp.headers.get("X-Idempotency-Replayed") === "true"; const body = await resp.json(); if (resp.status === 202) { // { call_id, status: "ringing", ... }. replayed === true means an // earlier attempt already placed this call; this is the stored response. console.log(`call ${body.call_id} for ${invoice.id} (replayed=${replayed})`); return body; } // Fresh or cached 4xx: a deterministic failure such as // 402 wallet.insufficient_balance. Same key returns the same answer — // fix the cause, then retry under a NEW key. throw new Error( `${body.error.code}: ${body.error.message} (request_id=${body.error.request_id})`, ); } throw new Error(`call for ${invoice.id} unconfirmed after 4 attempts`);}
The loop separates the three outcomes that matter. An ambiguous failure (timeout, connection reset) retries with the same key, so a call that did go out is never repeated. A 5xx retries with the same key and genuinely re-executes. A 4xx stops the loop — retrying a deterministic failure with the same key can only replay the failure, so escalate it instead. And a 202 with X-Idempotency-Replayed: true is your proof, in logs, that a duplicate was caught: something upstream sent this request twice, and exactly one phone rang.
The Idempotency-Key header dedupes requests you send to Sautikit. Events flowing back the other way have their own contract, and it is explicitly at-least-once: per the webhook retry semantics, a delivery that does not get a 2xx within 10 seconds is retried up to 8 attempts with exponential backoff from 30 seconds out to 7 days before dead-lettering. Duplicate deliveries are therefore expected behavior, and your handler needs its own dedup keyed on the event's event_id (also sent as the X-Sautikit-Event-Id header) — an INSERT ... ON CONFLICT DO NOTHING on that id is the whole trick. Verify the webhook signature before touching your dedup table, so a forged request cannot poison it, and see the webhooks concepts reference for the full delivery-header list.
The symmetry is worth internalizing: you dedupe your requests with Idempotency-Key; you dedupe Sautikit's events with event_id. Both sides of the pipe assume duplicates will happen and make them harmless.
For campaign-scale calling with AI voice agents there is a third layer, and it removes the job runner from the picture entirely. A broadcast holds its contact list server-side — rows are validated on upload with per-row rejects — and the platform owns each contact's attempt state: max_attempts, backoff_minutes, and retry_on (a subset of no_answer, busy, failed, voicemail) are campaign configuration, not code you write. When your infrastructure restarts mid-campaign, nothing re-dials, because the ledger of who has been called how many times never lived in your infrastructure to begin with. Broadcasts require a published agent, so for plain non-AI reminder blasts driven by your own scheduler, the calls API plus derived idempotency keys remains the pattern.
One more reason this discipline matters more now than it did two years ago: AI agents are becoming API clients. An agent driving your voice stack through the Sautikit MCP server uses a place_call tool that wraps the same POST /v1/calls — same auth, same billing, same idempotency machinery — and a well-behaved agent will retry a tool call that appears to fail, because that is what makes agents reliable. MCP clients already prompt a human before money-spending tools like place_call run, but confirmation guards the first attempt, not the ambiguous retry. Stable, business-derived idempotency keys are what make an agent's persistence safe to point at real phone numbers.