Idempotency Keys: Making POST Requests Safe to Retry

A
Author
11 Aug, 2026 2 min read 556 views

A client sends POST /payments, the network times out, and it retries. Did the first request succeed? Without protection, the customer may be charged twice. Idempotency keys make retries safe.

The contract

POST /payments
Idempotency-Key: 7f3b2c1e-9a4d-4e8b-a1f2-3c5d6e7f8a9b
{ "amount": 49900, "currency": "INR", "customer": "cus_42" }
  • The client generates a unique key (a UUID) per logical operation and reuses it on retries.
  • The server processes the first request and stores the response under that key.
  • Any repeat with the same key returns the stored response without doing the work again.

Storage

CREATE TABLE idempotency_keys (
  key           TEXT NOT NULL,
  account_id    BIGINT NOT NULL,
  request_hash  TEXT NOT NULL,
  status        TEXT NOT NULL DEFAULT 'processing',   -- processing | done
  response_code INT,
  response_body JSONB,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (account_id, key)
);

Handling a request

async function withIdempotency(req, res, handler) {
  const key = req.get('Idempotency-Key');
  if (!key) return handler();

  const hash = sha256(JSON.stringify(req.body));
  const inserted = await db.query(
    `INSERT INTO idempotency_keys (key, account_id, request_hash) VALUES ($1, $2, $3)
     ON CONFLICT DO NOTHING RETURNING key`,
    [key, req.account.id, hash]
  );

  if (inserted.rowCount === 0) {
    const existing = await db.one('SELECT * FROM idempotency_keys WHERE key = $1 AND account_id = $2', [key, req.account.id]);
    if (existing.request_hash !== hash) return res.status(422).json({ error: 'Key reused with a different request' });
    if (existing.status === 'processing') return res.status(409).json({ error: 'Request still in progress' });
    return res.status(existing.response_code).json(existing.response_body);
  }

  const { status, body } = await handler();
  await db.query(
    `UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4
     WHERE key = $1 AND account_id = $2`,
    [key, req.account.id, status, body]
  );
  return res.status(status).json(body);
}

Details that matter

  • Scope keys per account so one customer can't collide with another.
  • Reject a reused key with a different body; it's almost always a client bug.
  • Expire keys after 24 hours or so.
  • If the handler fails with a server error, delete the key (or allow retry) so the client can try again.
Was this article helpful?

Comments

No comments yet. Have a question or a tip? Be the first to share it.

Leave a comment

Comments are reviewed before they appear.