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.
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.
Generating keys: two valid strategies
Which one you want depends on whether the retry happens inside one process or across process restarts.
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));
}
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
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.
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.
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.
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.
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.