TestingAPIs is still being built. If something breaks, email support@testingapis.com.

Idempotency keys: retrying without duplicates

A payment that timed out but went through, and the header that makes retrying it safe.

Lessons 1 and 2 left a problem open. When a request that changes something fails with a timeout, a dropped connection or a 5xx, you can't tell whether the server did the work. For reads that doesn't matter, and lesson 3 retries them freely. For a POST that charges a card it matters a great deal: retry and you might charge twice, don't retry and you might lose a payment the customer meant to make. Idempotency keysremove the dilemma. This lesson shows the problem happening, explains how the keys work, and uses them on this API's payments endpoint.

The problem, for real

The payments endpoint takes a processingSeconds option that makes it slow on purpose. This script asks it to take 3 seconds, gives up after 1, waits, and then asks again:

demos/timeout-still-succeeds.mjs
// A payment request that times out on our side, and proof that the server created the payment anyway.
// Creates one test payment. Run: node demos/timeout-still-succeeds.mjs

import { BASE_URL, apiHeaders, sleep } from "../src/api.mjs";

const key = crypto.randomUUID();
const send = (timeoutMs, query = "") =>
  fetch(`${BASE_URL}/api/payments${query}`, {
    method: "POST",
    headers: apiHeaders({ "Content-Type": "application/json", "Idempotency-Key": key }),
    body: JSON.stringify({ userId: 1, amount: 49.5, currency: "usd", method: "card", cardLast4: "4242" }),
    signal: AbortSignal.timeout(timeoutMs),
  });

try {
  // The server takes 3 seconds over this one; we give up after 1.
  await send(1000, "?processingSeconds=3");
} catch (error) {
  console.log(`1st try: ${error.name} after 1 s. Did the payment happen? We can't tell.`);
}

await sleep(3000);

// Asking again with the same key is safe: if the payment exists, the server returns it instead of charging again.
const response = await send(10_000);
const body = await response.json();
console.log(`2nd try: ${response.status}, Idempotent-Replayed: ${response.headers.get("idempotent-replayed")}`);
console.log(`         payment ${body.data.id}, ${body.data.status}, created ${body.data.createdAt}`);
This example uses endpoints for signed-in members.Sign inCreate a free account
Terminal
$ node demos/timeout-still-succeeds.mjs
1st try: TimeoutError after 1 s. Did the payment happen? We can't tell.
2nd try: 201, Idempotent-Replayed: true
         payment 17, succeeded, created 2026-10-11T16:55:28Z

The first request timed out on the client's side, but the server carried on and created payment 17. If the second request had been an ordinary new POST, the customer would now have two payments. Instead the server recognized it as a repeat and returned payment 17 again, with a header saying so. What made that possible is one request header.

How an idempotency key works

An idempotency key is a unique value the client makes up for one operation, such as "this payment", and sends in an Idempotency-Keyheader. The server keeps a record of the keys it has seen and the response it gave for each. When a request arrives with a key it already knows, it doesn't do the work again. It sends back the response it saved the first time.

Diagram
client                                         server
  │                                               │
  │  POST /api/payments                           │
  │  Idempotency-Key: 0a9d1bf2-...                │
  │ ────────────────────────────────────────────► │  key not seen before:
  │                                               │  create the payment, then
  │                                               │  store key → response
  │          ✕ the response is lost               │
  │                                               │
  │  POST /api/payments  (same key, same body)    │
  │ ────────────────────────────────────────────► │  key seen before:
  │                                               │  don't create anything,
  │ ◄──────────────────────────────────────────── │  send the stored response
  │  201 Created, Idempotent-Replayed: true       │
This example uses endpoints for signed-in members.Sign inCreate a free account

Lesson 3 called a request idempotentif sending it twice has the same effect as sending it once. POST isn't idempotent by nature, but with a key it behaves as if it were: however many times the client sends the same keyed request, the server acts once. That's why shouldRetry in lesson 3 allows a POST to be retried when a key is present.

The header is in wide use among payment APIs, and an IETF draft, "The Idempotency-Key HTTP Header Field", aims to standardize it. The details of the server's behavior vary between APIs, so check the documentation. This API's rules are below.

How this API handles keys

A first payment, with a key the client generated:

Terminal
$ curl -i -X POST https://api.testingapis.com/api/payments \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 0a9d1bf2-d271-4ad7-aa6a-6a2ecabcca98" \
    -d '{"userId":1,"amount":19.99,"currency":"usd","method":"card","cardLast4":"4242","description":"resilience tutorial test"}'
HTTP/1.1 201 Created
This example uses endpoints for signed-in members.Sign inCreate a free account
Response body
{
  "message": "Payment created successfully",
  "idempotencyKey": "0a9d1bf2-d271-4ad7-aa6a-6a2ecabcca98",
  "data": {
    "id": 13,
    "userId": 1,
    "orderId": null,
    "amount": 19.99,
    "currency": "USD",
    "method": "card",
    "status": "succeeded",
    "description": "resilience tutorial test",
    "cardLast4": "4242",
    "createdAt": "2026-10-11T16:45:45Z",
    "refundedAt": null
  }
}

The same request again, with the same key. The JSON fields are in a different order this time, to show that the server compares meaning rather than exact text:

Terminal
$ curl -i -X POST https://api.testingapis.com/api/payments \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 0a9d1bf2-d271-4ad7-aa6a-6a2ecabcca98" \
    -d '{"description":"resilience tutorial test","cardLast4":"4242","method":"card","currency":"usd","amount":19.99,"userId":1}'
HTTP/1.1 201 Created
idempotent-replayed: true

{"message":"Payment created successfully","idempotencyKey":"0a9d1bf2-d271-4ad7-aa6a-6a2ecabcca98","data":{"id":13, ...}}
This example uses endpoints for signed-in members.Sign inCreate a free account

The same payment, ID 13, and an Idempotent-Replayed: true header telling the client this is a stored response, not a new payment. Now two mistakes. The same key with a different amount, and no key at all:

Terminal
$ curl -i -X POST https://api.testingapis.com/api/payments \
    -H "Content-Type: application/json" \
    -H "Idempotency-Key: 0a9d1bf2-d271-4ad7-aa6a-6a2ecabcca98" \
    -d '{"userId":1,"amount":29.99,"currency":"usd","method":"card","cardLast4":"4242","description":"resilience tutorial test"}'
HTTP/1.1 422 Unprocessable Entity

{"status":422,"error":"Unprocessable Entity","message":"This Idempotency-Key was already used with a different request body."}

$ curl -i -X POST https://api.testingapis.com/api/payments \
    -H "Content-Type: application/json" \
    -d '{"userId":1,"amount":19.99,"currency":"usd","method":"card","cardLast4":"4242","description":"resilience tutorial test"}'
HTTP/1.1 400 Bad Request

{"status":400,"error":"Bad Request","message":"Missing Idempotency-Key header. Send a unique value (e.g. a UUID) per logical payment."}
This example uses endpoints for signed-in members.Sign inCreate a free account

One more case is easy to miss. What if the repeat arrives while the first request is still being processed? Using processingSeconds=3 on the first request and sending a second one half a second later:

Result
second  409   126 ms  {"status":409,"error":"Conflict","message":"A request with this Idempotency-Key is still being processed. Retry shortly."}
first   201  3301 ms  {"message":"Payment created successfully", ... "id":14 ...}
third   201    40 ms  Idempotent-Replayed: true, the same payment 14
SituationResponseWhat the client should do
New key201, payment createdUse it
Known key, same body, first request finishedThe saved response, with Idempotent-Replayed: trueUse it; it's the same payment
Known key, first request still running409 ConflictWait a moment and send the same request again
Known key, different body422 Unprocessable EntityFix the bug: a key was reused for a different payment
No key400 Bad RequestSend one; this endpoint requires it
Body fails validation400, and the key isn't rememberedFix the body; the same key can be used again

Keys are remembered for up to 24 hours. Because this is a shared demo API, it also keeps no more than 1,000, and drops the oldest finished ones first when it reaches that. A forgotten key counts as new. Two of these rules protect you from your own bugs: the 422 catches a key reused for a different payment, and the 409 stops two copies of the same request racing each other on the server.

Making good keys

The key has to be unique to one operation and stable across every attempt at that operation. In practice:

  • Generate it with crypto.randomUUID(), which gives a random version 4 UUID such as 0a9d1bf2-d271-4ad7-aa6a-6a2ecabcca98. With 122 random bits, two clients producing the same one by chance isn't a practical concern.
  • Make it once, when the operation starts, such as when the customer presses Pay. Not inside the retry loop.
  • If the operation could outlive the process, store the key with it. An app that crashes after sending the payment and restarts should retry with the key it saved, not a new one.
  • Don't build the key from the payment's contents, such as a hash of the amount and customer. Two genuine payments of the same amount by the same customer would then look like one.

The most common mistake is the first point in reverse:

Wrong and right
// Wrong: a new key for every attempt. Each retry looks like a new payment to the server.
await withRetries(() =>
  fetch(url, { method: "POST", headers: { "Idempotency-Key": crypto.randomUUID() }, body }),
);

// Right: one key for the payment, made before the first attempt and reused by every retry.
const key = crypto.randomUUID();
await withRetries(() => fetch(url, { method: "POST", headers: { "Idempotency-Key": key }, body }), {
  method: "POST",
  idempotencyKey: key,
});

The wrong version sends a valid key every time, so the server accepts every attempt as a new payment. The bug stays hidden until a retry happens after a payment that did go through, and then it charges twice.

In the example project

src/idempotency.mjs wraps a payment in exactly that pattern:

src/idempotency.mjs
// Creating a payment so that retries can never charge twice.

import { BASE_URL, apiHeaders } from "./api.mjs";
import { withRetries } from "./retry.mjs";
import { fetchWithTimeout } from "./timeout.mjs";

export async function createPayment(payment, { key = crypto.randomUUID(), ...retryOptions } = {}) {
  // One key per payment, made once, before the first attempt. Every retry of this payment sends the same key,
  // so the server can tell a retry from a new payment.
  const response = await withRetries(
    () =>
      fetchWithTimeout(`${BASE_URL}/api/payments`, {
        method: "POST",
        headers: apiHeaders({ "Content-Type": "application/json", "Idempotency-Key": key }),
        body: JSON.stringify(payment),
        timeoutMs: 10_000,
      }),
    { method: "POST", idempotencyKey: key, ...retryOptions },
  );
  return { key, response };
}
This example uses endpoints for signed-in members.Sign inCreate a free account

The key is a parameter with a default, so a caller that stored a key can pass it back in, and it's created before withRetries starts. Passing idempotencyKey to withRetries tells it a retry is safe even though the method is POST. The function returns the key with the response, so the caller can save it next to the order.

The tests check the replay and the mismatch:

test/idempotency.test.mjs
test("sending the same key twice creates one payment", async () => {
  const { key, response } = await createPayment(payment);
  assert.equal(response.status, 201);
  const first = await response.json();

  const repeat = await post(key, payment);
  assert.equal(repeat.status, 201);
  assert.equal(repeat.headers.get("idempotent-replayed"), "true");
  assert.equal((await repeat.json()).data.id, first.data.id);

  // The same key with a different body is a mistake in the client, and the server says so.
  const changed = await post(key, { ...payment, amount: 29.99 });
  assert.equal(changed.status, 422);
});

And the request that arrives while the first is still running:

test/idempotency.test.mjs
test("a repeat sent while the first is still processing gets 409", async () => {
  const key = crypto.randomUUID();
  const first = post(key, payment, "?processingSeconds=2"); // the server holds this one for 2 seconds
  await new Promise((resolve) => setTimeout(resolve, 500));

  const early = await post(key, payment);
  assert.equal(early.status, 409);

  assert.equal((await first).status, 201);
  const late = await post(key, payment);
  assert.equal(late.headers.get("idempotent-replayed"), "true");
});

If you build the API

The server side is harder than the client side. A server that accepts idempotency keys has to:

  • Record the key beforedoing the work, in a single atomic step, so two copies arriving together can't both see "new key". That's what the 409 above comes from.
  • Save the full response, status and body, and replay it exactly.
  • Compare the body of a repeat with the original and refuse a mismatch.
  • Keep keys per account, so one customer's key can never return another customer's payment.
  • Expire keys after a set time, and document it.

The same idea appears on the receiving end of webhooks, where the sender retries and the receiver has to spot duplicates by event ID. The webhook delivery lesson covers that side.

Check your understanding

  1. 1.Why can't a client simply retry a POST that timed out?Show answer

    The server may have finished the work after the client stopped waiting, as payment 17 shows. A plain retry would do it a second time.

  2. 2.Where in the code should the key be created, and why?Show answer

    Once, before the first attempt, and reused by every retry of that operation. A key made inside the retry loop is different every time, so the server treats each attempt as new.

  3. 3.What does a 422 from this endpoint tell you?Show answer

    The key was already used with a different body. That's a client bug: one key was used for two payments.

  4. 4.A retry gets 409 Conflict. What happened, and what should the client do?Show answer

    The first request with that key is still being processed. Wait briefly and send the same request, same key, again. Once the first finishes, the retry gets its saved response.

  5. 5.Why not use a hash of the request body as the key?Show answer

    Two real payments with identical details would share a key, and the second would never happen.

Exercise

Extend createPayment so it also retries a 409, by waiting a second and sending the same request again, at most three times. Test it with processingSeconds=2 on a first request and a second createPayment call with the same key, started half a second later: the second call should end up with the same payment ID as the first. Use cardLast4: "0002" once to see what a declined payment returns, and whether its replay is declined too. The payments API reference lists the test card numbers.

Practise and go further