---
title: 'Never call the same person twice: idempotency keys for voice APIs'
description: >-
  Stop duplicate calls from timeouts and queue retries: exact Idempotency-Key
  semantics on the Sautikit API, business-identity keys, and a safe retry loop.
summary: >-
  A timeout plus a retry on POST /v1/calls means a customer gets called twice.
  Where duplicates come from, exact Idempotency-Key semantics, key derivation,
  and a retry loop that cannot double-dial.
date: 2026-10-27T00:00:00.000Z
type: blog
---


## Summary

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.

## Duplicate side effects are audible

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](/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.

## Where duplicates come from

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.

## How Idempotency-Key works on the Sautikit API

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.

## Derive keys from business identity, not randomness

The most common way teams defeat their own idempotency layer is this:

```js
// 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:

```js
// 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.

## Passing the key: the SDK does the header for you

If you place calls from Node, the SDK takes the derived key as a field and sends it as the `Idempotency-Key` header:

```bash
npm install @sautikit/node@0.2.0
```

> 
> 
> `@sautikit/node` is in preview — pin the version; the API may change before 1.0.
> 
> 

```js
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 **omit** `idempotencyKey`, 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.

## A retry loop that cannot double-dial

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.

```js
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.

## Webhooks: the other half of the contract

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](/blog/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](/blog/webhook-signature-verification) before touching your dedup table, so a forged request cannot poison it, and see the [webhooks concepts reference](/developers/concepts/webhooks) 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.

## Broadcasts: dedup at the contact level

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](/developers/api) 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.

## Agents retry by design

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](/mcp) 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.

## Get started

1. [Create a Sautikit workspace](/) and claim a phone number.
2. Top up over **M-Pesa**: KES billing, no card.
3. Add an `Idempotency-Key` derived from business identity to every `POST /v1/calls` your job runner sends — before the first retry teaches you why.

**[Start with Sautikit →](/)** &nbsp;·&nbsp; **[See pricing →](/pricing)** &nbsp;·&nbsp; **[Need SMS, WhatsApp & an agent desk? Helloduty →](https://helloduty.com)**

## Further reading

- [Webhook retry semantics: surviving failures with at-least-once delivery](/blog/webhook-retry-semantics)
- [Verify Sautikit webhook signatures](/blog/webhook-signature-verification)
- [Bill every call exactly once: a prepaid voice wallet ledger](/blog/prepaid-wallet-ledger-design)
- [Webhooks concepts reference](/developers/concepts/webhooks)
- [API reference](/developers/api)
