writing/17 Aug 2026/10 min read

Idempotency keys: the recipe, not the theory

Your POST times out. Did it work? You cannot tell from a timeout, because the failure happened on the way back. Retry and you might create a duplicate; do not retry and you might have created nothing. An idempotency key is how you stop guessing.

The ambiguous failure

A network request has more failure modes than success modes, and only some of them tell you what happened on the server.

Connection refused before sending
safe to retry
Request sent, connection dropped mid-flight
unknown
Client timeout while waiting
unknown
502 or 504 from a proxy
unknown
Process killed after the write, before the response
already succeeded

Three of those five are indistinguishable from the client's side. The write may have committed and the acknowledgement may have been lost. Retrying is the only sane behaviour for a resilient client, and with a naive POST endpoint, retrying is precisely how you end up with two of everything.

GET, PUT and DELETE are naturally idempotent: doing them twice leaves the same state. POST is not, which is why it needs help.

How the key works

You generate a unique string, attach it to the request, and the server remembers it alongside the result.

POST /links
Idempotency-Key: 7c9e6a02-1f4b-4a8e-9d33-5b8c2f1e4a77
{ "destination_url": "https://venture.example/launch", "slug": "launch26" }

→ 201  { "data": { "id": "lnk_42", "version": 1 } }

# the same request again, after a timeout you were unsure about
POST /links
Idempotency-Key: 7c9e6a02-1f4b-4a8e-9d33-5b8c2f1e4a77
{ "destination_url": "https://venture.example/launch", "slug": "launch26" }

→ 201  { "data": { "id": "lnk_42", "version": 1 } }   ← same link, not a second one

The second call did no work. The server recognised the key, found the stored outcome, and replayed it. Your client cannot tell the difference, and does not need to.

Alongside the key, a well-built server stores a hash of the canonical request body. That guards against the dangerous case below.

Same key, different body

This is the failure people do not anticipate. A client reuses a key — often because it was derived from something insufficiently unique, like a date or a campaign name — but the payload has changed.

POST /links
Idempotency-Key: launch-2026
{ "destination_url": "https://venture.example/launch" }     → 201

POST /links
Idempotency-Key: launch-2026
{ "destination_url": "https://venture.example/pricing" }    → 409 Conflict

The server has two bad options and one good one. It could ignore the key and create a second link, defeating the purpose. It could replay the first result, which is worse — you asked for a pricing link and would receive a launch link with a 201, believing your request succeeded. Instead it rejects with 409, because the only honest answer is that your request is contradictory.

A 409 from an idempotency mismatch is a bug in your key generation, not a transient error. Never retry it with the same key. Find out why two different requests carried one identifier.

Generating keys: two valid strategies

Which one you want depends on whether the retry happens inside one process or across process restarts.

Random, generated once per logical operation

A UUID v4, created before the first attempt and reused for every retry of that same attempt. Simple and correct for in-process retries. It gives you nothing if the process dies and restarts, because the new process invents a new key.

const key = crypto.randomUUID();
// generated OUTSIDE the retry loop
for (let i = 0; i < 3; i++) {
  const res = await post("/links", body, { "Idempotency-Key": key });
  if (res.ok || res.status < 500) return res;
  await sleep(backoff(i));
}
Derived from the work itself

A deterministic string built from whatever uniquely identifies the operation in your domain: a job id, a row id, a content hash. The same job produces the same key after a crash, a redeploy or a queue redelivery, which is exactly what you want for batch work.

// safe: stable across restarts, unique per row
const key = `import:${importRunId}:row:${rowIndex}`;

// also safe: content-derived
const key = `link:${sha256(destination + slug).slice(0, 32)}`;

What is never safe is a key that is stable across genuinely different operations. "daily-import" or "2026-09-12" will collide the second time you run anything, and you will get a 409 you did not expect.

On c1k.me the key is 16 to 128 ASCII characters and is scoped to your credential, your organization and the operation, so two different services using the same string cannot interfere with each other.

Four mistakes, in order of frequency

01
Generating the key inside the retry loop

Every attempt gets a fresh key, so every attempt is a new operation, so a retry after a lost acknowledgement creates a duplicate. This defeats the entire mechanism while appearing to use it, and it is by far the most common error.

02
Reusing one key for a batch

Three hundred rows, one key. The first row is created, the remaining 299 either replay its result or 409. Each logical creation needs its own key.

03
Treating 409 as retryable

Backing off and retrying a body mismatch just fails more slowly. Distinguish it from a 429, which genuinely is retryable, by reading error.code rather than branching on the status alone.

04
Expecting keys to be remembered forever

Servers retain keys for a bounded window, typically hours to a day. A retry a week later is a new operation. If your job can be redelivered after that long, deduplicate on your own side too.

Keys and AI agents

Agents fail in a specific way here. A model calls a create tool, the call times out or the transcript is truncated, and the model retries — but as a language model it regenerates arguments rather than replaying the exact previous call. A fresh random key would appear each time.

This is why c1k.me makes idempotency_key a required argument on create_link rather than generating one server-side. Being explicit forces the value into the conversation, where a model can reuse it deterministically, and where a human reading the transcript can see whether two calls were the same operation or two different ones.

Encourage semantic keys in agent contexts: launch26-newsletter-2026-09-12 is something a model can reconstruct from the same instruction, where a UUID is not.

The recipe

async function createOnce(body, key) {
  // key comes IN. Caller owns its stability across restarts.
  for (let attempt = 0; attempt < 4; attempt++) {
    let res;
    try {
      res = await fetch(`${BASE}/links`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": key
        },
        body: JSON.stringify(body)
      });
    } catch (networkError) {
      await sleep(jitter(attempt));   // unknown outcome; key makes retry safe
      continue;
    }

    if (res.ok) return (await res.json()).data;

    if (res.status === 429) {
      await sleep(Number(res.headers.get("Retry-After") || 5) * 1000);
      continue;
    }
    if (res.status >= 500) { await sleep(jitter(attempt)); continue; }

    // 409, 422, 401, 403: deterministic. Retrying changes nothing.
    const { error } = await res.json();
    throw new Error(`${res.status} ${error.code}: ${error.message}`);
  }
  throw new Error("gave up after 4 attempts");
}

const jitter = n => (2 ** n) * 250 + Math.random() * 250;

Note what is outside the loop: the key. Note what is retried: network failures, 429 and 5xx. Note what throws immediately: everything deterministic. That is the entire pattern.

The short version

One key per logical creation, generated before the first attempt and reused for every retry of it. Random for in-process work, derived from a job or row id when a restart must resume safely. Retry network errors, 429 and 5xx; never retry a 409. And remember that idempotency keys protect creates, while If-Match protects updates — a client that handles concurrency needs both.