Ch. 10 · Microservices

Microservice Idempotency Keys and Repeated Requests

How an Idempotency-Key header lets a client retry a payment POST safely: claim the key, fingerprint the body, replay the stored response.

~6 min readbeginnerupdated Oct 7, 2026

A customer taps “Pay 42.50 euros”. The mobile app sends POST /payments. The server charges the card, and then the response gets lost: the phone switched from Wi-Fi to mobile data, or a load balancer timed out. From the app’s point of view, the request failed. It doesn’t know whether the card was charged.

If the app retries, it might charge twice. If it doesn’t, it might show an error for a payment that went through. Neither is acceptable. Idempotency keys solve this by letting the client retry safely: the server recognises the retry as the same operation and returns the original result instead of doing the work again.

Why HTTP methods alone don’t save you

HTTP already classifies methods. GET, PUT and DELETE are defined as idempotent: sending PUT /orders/991/address with the same body twice leaves the same state as sending it once. Clients and proxies may retry them.

POST isn’t idempotent. “Create a payment” twice creates two payments. And unfortunately most operations worth worrying about (create an order, charge a card, send a transfer) are naturally POSTs. You could redesign them as PUT /payments/{client-generated-id}, which is a valid approach, but the more common pattern in public APIs is an Idempotency-Key header on the POST. Stripe, Adyen and others use it, and there’s an IETF draft standardising the header.

The server side, step by step

Here’s a minimal payment endpoint in Node 22. The key store is a Map to keep the example short; in production it’s a database table with a unique constraint.

const keys = new Map();   // in production: a table with a unique constraint on (client_id, key)
let charges = 0;

const server = http.createServer(async (req, res) => {
  let body = ''; for await (const chunk of req) body += chunk;
  const key = req.headers['idempotency-key'];
  if (!key) { res.writeHead(400).end('Idempotency-Key header required'); return; }
  const fingerprint = createHash('sha256').update(req.method + req.url + body).digest('hex');

  const existing = keys.get(key);
  if (existing) {
    if (existing.fingerprint !== fingerprint) { res.writeHead(422).end('key reused with a different request'); return; }
    if (existing.state === 'in_progress') { res.writeHead(409).end('original request still in progress'); return; }
    res.writeHead(existing.status, { 'idempotent-replayed': 'true' }).end(existing.body); return;
  }
  keys.set(key, { fingerprint, state: 'in_progress' });          // claim the key before doing work

  await sleep(100);                                              // call the card network
  charges++;
  const result = JSON.stringify({ paymentId: `pay_${charges}`, amount: JSON.parse(body).amount });
  keys.set(key, { fingerprint, state: 'done', status: 201, body: result });
  res.writeHead(201).end(result);
});
JavaScript

The client script sends a payment, fires an impatient retry 20 ms later while the first is still running, retries again after the first completes, then misuses the key with a different amount, and finally makes a genuinely new purchase:

impatient retry, same key          409 original request still in progress
first attempt                      201 {"paymentId":"pay_1","amount":4250}
retry after timeout, same key      201 (replayed) {"paymentId":"pay_1","amount":4250}
same key, different amount         422 key reused with a different request
new purchase, new key              201 {"paymentId":"pay_2","amount":1200}
card network charged 2 times
Text

Two purchases, two charges. Every retry was absorbed. Each branch of the handler is there for a reason.

Claim the key before doing the work. The handler records in_progress before calling the card network. If it only stored the key after charging, two requests arriving close together would both find no record and both charge. In a database, the claim is an INSERT into a table with a unique constraint on the key: exactly one concurrent insert succeeds, and the other gets a constraint violation. A “check if it exists, then insert” sequence has the same race the Map example avoids only because Node runs this handler’s synchronous part without interruption.

Answer a concurrent duplicate with 409. The impatient retry arrived while the original was still running. The server can’t replay a response it doesn’t have yet, and it must not start a second charge. 409 Conflict tells the client to wait and retry later. (Some implementations wait for the first request to finish and then replay, which is friendlier but ties up a connection.)

Replay the stored response. The retry after the first one completed got the original 201 with the same paymentId. The client can’t tell the difference between “my first request worked” and “my retry was recognised”, which is the point. The idempotent-replayed header is optional, but it makes debugging much easier.

Fingerprint the request. The same key with a different amount is almost certainly a client bug, such as a key generated once per screen instead of once per payment. Replaying the old response would hide the bug, and processing the new request would break the guarantee. 422 makes it loud.

The client’s half of the contract

Server logic is wasted if the client generates keys wrongly. The rules:

  • One key per logical operation. Generate a UUID when the user presses “Pay”, store it with the pending payment, and reuse it on every retry of that payment. A new key per HTTP attempt makes the whole mechanism useless, and it’s the most common mistake.
  • Persist the key if the operation can outlive the process. If the app is killed and reopened, the pending payment should still have its key so the retry is recognised.
  • Retry with backoff, and only on ambiguous failures. Timeouts, connection resets, 409, 5xx. A 422 or 400 won’t change on retry.

What the server must also get right

Scope and expiry. Keys should be scoped to the client or account (two customers could generate the same key, however unlikely with UUIDs) and kept long enough to cover realistic retries. Stripe documents keeping keys for at least 24 hours. After that, a retry with an old key is treated as new.

Which results to store. Stripe stores the first result for a key whether it succeeded or failed, including 500s, so a retry returns the same error. That’s simple and predictable, but it means a transient failure can’t be fixed by retrying with the same key. Other APIs release the key on 5xx so a retry can try again. Either is defensible if documented; the important rule is to never store results for requests that failed validation before any work started, since those are cheap to repeat.

Crashes mid-request. If the server crashes after claiming the key and before storing the result, the key sits in in_progress forever and every retry gets 409. Give the claim a lease (a locked_until timestamp) so a retry after the lease expires can take over. Then the next question is whether the charge happened before the crash, which is why the call to the card network should itself carry an idempotency key derived from yours. The provider deduplicates, and the takeover retry is safe.

Atomicity with the business write. Ideally the payment row and the stored response are written in the same database transaction. Then “key marked done” and “payment recorded” can’t disagree.

Interview practice: a double order from the web checkout

“Your web checkout sends POST /orders with an Idempotency-Key header. The server implements keys correctly and tests pass. Yet support reports a few customers per week with duplicate orders placed seconds apart, each with a different key. What’s going on?”

Answer

Different keys means the server is behaving correctly: it saw two distinct operations. The duplication is on the client side, so I’d look at how the key is generated. The likeliest cause is that the key is created per request rather than per checkout attempt, for example inside the fetch wrapper, so a retry after a timeout, or a double click on “Place order”, produces a new key each time.

The fix is to generate the key when the checkout attempt starts (when the order summary is shown, or on the first click) and keep it in component state, or in session storage so a page reload doesn’t lose it. Every send of that checkout uses the same key, and the button should be disabled while a request is in flight. After a successful response, the next checkout gets a new key. I’d confirm by checking server logs: the duplicate pairs should show two keys with identical fingerprints a few seconds apart, which is the signature of a client generating keys per attempt.

Further reading

More in Microservices

esc