Coding

Idempotency keys in Node.js: safe retries for POST endpoints without double charges

A Postgres-backed Express middleware, the 409 and 422 rules, key expiry, and the 5xx decision that decides correctness.

Idempotency keys stop double charges. Cover graphic for a guide to making POST retries safe with idempotency keys in Node.js and Postgres.

A customer on a flaky mobile connection taps “Pay”, the request times out on their side, the app retries, and your API charges them twice. Your code did nothing wrong on either request. The problem is that POST is not idempotent, and the client had no way to say “this is the same attempt as before”.

An idempotency key fixes that. The client generates a unique key (a UUID) once per logical operation and sends it in an Idempotency-Key header on every attempt. The server stores the key with the result of the first attempt; any retry with the same key gets the stored response instead of running the operation again. Below is a working Express implementation on Postgres, the edge cases that bite (concurrent retries, changed payloads, crashes mid-request), and how long to keep keys.

Why POST needs an idempotency key and PUT usually does not

HTTP defines GET, HEAD, PUT and DELETE as idempotent: sending the same request twice has the same effect as sending it once (RFC 9110, section 9.2.2). POST and PATCH are not. “Create a payment” twice creates two payments.

Clients retry for good reasons: timeouts, dropped connections, a 502 from a load balancer during your deploy. If you have set sensible client timeouts (see timeouts everywhere in Node.js), retries will happen, and the server must be the one that makes them safe.

How the Idempotency-Key header works

The IETF httpapi working group has a draft standard for this header (revision 07, October 2025). It is not an RFC yet, but its rules match what payment APIs such as Stripe have done for years:

Situation Server response
First request with a new key Run the operation, store status and body, return it
Retry with the same key and same payload, finished Return the stored response, do not run again
Same key, request still running 409 Conflict, client may retry later
Same key, different payload 422 Unprocessable Content, client bug
Key missing on an endpoint that requires it 400 Bad Request

The draft recommends a UUID or similar random value as the key, and says servers should publish how long keys are kept. Stripe’s documentation says keys can be pruned once they are at least 24 hours old and that the stored response is returned even when it was a 500.

Implementing idempotency keys in Express with Postgres

Step 1: a table with a unique key per caller

sql
CREATE TABLE idempotency_keys (
  account_id    bigint      NOT NULL,
  key           text        NOT NULL,
  fingerprint   text        NOT NULL,
  status        text        NOT NULL DEFAULT 'running',  -- running | done
  response_code int,
  response_body jsonb,
  created_at    timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (account_id, key)
);
CREATE INDEX ON idempotency_keys (created_at);

Scope keys to the caller. Without account_id in the primary key, two customers who happen to send the same key collide, and one could read the other’s stored response.

Step 2: the middleware

js
// idempotency.js
import { createHash } from 'node:crypto';
import { pool } from './db.js';

const KEY_RE = /^[A-Za-z0-9_-]{8,255}$/;
const STALE_MS = 60_000; // a "running" row older than this is treated as abandoned

export function idempotent({ required = true } = {}) {
  return async (req, res, next) => {
    const key = req.get('Idempotency-Key')?.replace(/^"|"$/g, '');
    if (!key) {
      return required ? res.status(400).json({ error: 'Idempotency-Key header required' }) : next();
    }
    if (!KEY_RE.test(key)) return res.status(400).json({ error: 'invalid Idempotency-Key' });

    const accountId = req.user.accountId;
    const fingerprint = createHash('sha256')
      .update(`${req.method} ${req.baseUrl}${req.path}\n${JSON.stringify(req.body ?? null)}`)
      .digest('hex');

    const inserted = await pool.query(
      `INSERT INTO idempotency_keys (account_id, key, fingerprint)
       VALUES ($1, $2, $3) ON CONFLICT DO NOTHING RETURNING key`,
      [accountId, key, fingerprint],
    );

    if (inserted.rowCount === 0) {
      const { rows: [row] } = await pool.query(
        'SELECT * FROM idempotency_keys WHERE account_id = $1 AND key = $2',
        [accountId, key],
      );
      if (row.fingerprint !== fingerprint) {
        return res.status(422).json({ error: 'Idempotency-Key reused with a different request' });
      }
      if (row.status === 'done') {
        res.set('Idempotent-Replayed', 'true');
        return res.status(row.response_code).json(row.response_body);
      }
      if (Date.now() - row.created_at.getTime() < STALE_MS) {
        return res.status(409).json({ error: 'request with this Idempotency-Key is in progress' });
      }
      // Abandoned by a crashed process: take it over.
      await pool.query(
        'UPDATE idempotency_keys SET created_at = now() WHERE account_id = $1 AND key = $2',
        [accountId, key],
      );
    }

    const json = res.json.bind(res);
    res.json = (body) => {
      const done = res.statusCode < 500
        ? pool.query(
            `UPDATE idempotency_keys SET status = 'done', response_code = $3, response_body = $4
             WHERE account_id = $1 AND key = $2`,
            [accountId, key, res.statusCode, body],
          )
        : pool.query('DELETE FROM idempotency_keys WHERE account_id = $1 AND key = $2', [accountId, key]);
      done.catch((err) => req.log?.error({ err }, 'idempotency write failed'));
      return json(body);
    };
    next();
  };
}

Step 3: use it on the routes that move money or create things

js
app.post('/v1/payments', authenticate, idempotent(), async (req, res) => {
  const payment = await createPayment(req.user.accountId, req.body);
  res.status(201).json(payment);
});

Express 5 forwards rejected promises from async handlers to your error middleware, so a thrown error here reaches it, and the error handler’s res.json call deletes the key so the client can retry.

The decisions you have to make

Store 5xx responses or let them retry?

Stripe stores the first response, including a 500. That is the safest choice when a failure might have happened after the side effect (the charge went through, then the database write failed). The middleware above takes the other path: it deletes the key on a 5xx so the client can retry. That is only safe if your handler is transactional, meaning a 5xx guarantees nothing was committed. If the handler calls an external payment provider, pass the same key through to the provider so their side is idempotent too.

How long to keep keys

Keep them at least as long as clients retry, plus a margin. 24 hours is a common choice. Delete old rows on a schedule:

sql
DELETE FROM idempotency_keys WHERE created_at < now() - interval '24 hours';

Document the window in your API docs, as the draft asks.

Redis instead of Postgres

If you already run Redis, SET key value NX EX 86400 gives you the same “first writer wins” step. Keep Postgres if the operation itself writes to Postgres: storing the key in the same transaction as the payment row removes a whole class of crash-in-between bugs.

The client side

The key must be created once per user intent, not once per HTTP attempt:

js
const key = crypto.randomUUID();   // when the user clicks Pay
for (let attempt = 1; attempt <= 3; attempt++) {
  const res = await fetch('https://api.example.com/v1/payments', {
    method: 'POST',
    headers: { 'content-type': 'application/json', 'idempotency-key': key },
    body: JSON.stringify({ amount: 4200, currency: 'usd' }),
    signal: AbortSignal.timeout(10_000),
  }).catch(() => null);
  if (res && res.status !== 409 && res.status < 500) break;
  await new Promise((r) => setTimeout(r, 2 ** attempt * 250));
}

Never use an email address or other personal data as the key.

If you only do one thing

  • Add the idempotency_keys table and middleware to every endpoint that charges, sends or creates something a user would notice twice.
  • Scope keys per account, fingerprint the payload, and return 409 and 422 as above.
  • Purge keys older than 24 hours.

A one-person team can ship this in an afternoon for the one payment route that matters. A ten-person team should make it a shared middleware and add “is this POST idempotent?” to the review checklist, next to rate limiting.

FAQ

Is an idempotency key the same as a request ID?

No. A request ID identifies one HTTP attempt and changes on every retry. An idempotency key identifies the user’s intent and stays the same across retries.

Should the idempotency key go in a header or the body?

A header. It keeps the business payload clean, works the same on every endpoint, and matches the IETF draft and Stripe’s API.

How do I test idempotency?

Send the same request twice in parallel and check that only one row was created and the second call got a 409 or the replayed response. Then send the same key with a changed body and expect 422.

Doing this with ApiTraffic

If you would rather not hunt for duplicate charges in raw logs, ApiTraffic records every request and response, so you can tag each one with its key and see every attempt side by side:

js
apitraffic.tag('idempotencyKey', key);

Then search for conflicts, url_path == "/v1/payments" AND status_code == 409, or for one customer’s attempts with tag:userId == "1042" AND status_code >= 400. Outbound calls are logged too, so you can confirm your payment provider received the request once.

Start free and see every request your API handles: apitraffic.io

Sources: IETF, “The Idempotency-Key HTTP Header Field”, draft-ietf-httpapi-idempotency-key-header-07 (Oct 15, 2025); RFC 9110, HTTP Semantics, section 9.2.2 (June 2022); Stripe API reference, “Idempotent requests”

Keep reading