Overview

A user clicks "Pay now." The request reaches your server, charges the card, and then the connection drops before the response gets back. The user clicks again. They get charged twice. You spend the next week refunding and apologizing.

This is not a rare edge case. It's a phone dropping Wi-Fi, a load balancer timing out, a client library retrying on its own. Every payment API in the world solves it the same way, and every API that handles state-changing operations should too.

The pattern in one sentence

The client sends a unique key with the request. If the server sees the same key again, it returns the original response instead of performing the operation a second time.

POST /api/payments
Idempotency-Key: 4c8a3e2b-1f9d-4e5a-9b6c-8d7e6f5a4b3c
Content-Type: application/json

{
  "amount": 2500,
  "currency": "usd",
  "customer_id": "cus_abc123"
}

The client generates a UUID, sends it with the request, and if anything goes wrong — timeout, connection drop, retry logic — it sends the same key again. The server sees the key, recognizes it, and returns the stored response from the first execution.

What the server needs to store

FieldPurpose
Idempotency keyThe unique identifier from the client
Request fingerprintHash of the request body — detects key reuse with different data
Response statusTo return the same status code
Response bodyTo return the same response
Statein_progress or completed — handles concurrent requests
Created atFor cleanup of old keys

The request fingerprint is what catches the case where a client reuses a key with different data. That's a bug on the client side, and returning a 422 is better than silently doing the wrong thing.

Implementation in Node.js

import crypto from "crypto";
import express from "express";

const app = express();
app.use(express.json());

// In production this is Redis or a Database table
const idempotencyStore = new Map();

function fingerprint(body) {
  return crypto
    .createHash("sha256")
    .update(JSON.stringify(body))
    .digest("hex");
}

async function idempotency(req, res, next) {
  const key = req.headers["idempotency-key"];

  if (!key) {
    // Idempotency is optional for GET, required for mutations
    if (req.method !== "GET") {
      return res.status(400).json({ error: "Idempotency-Key header required" });
    }
    return next();
  }

  const existing = await idempotencyStore.get(key);

  if (existing) {
    // Same key, same body — replay the response
    if (existing.fingerprint === fingerprint(req.body)) {
      if (existing.state === "in_progress") {
        return res.status(409).json({ error: "Request in progress" });
      }
      return res
        .status(existing.status)
        .set("Idempotency-Replayed", "true")
        .json(existing.body);
    }

    // Same key, different body — client bug
    return res.status(422).json({
      error: "Idempotency key reused with different request body",
    });
  }

  // Mark in progress before processing
  await idempotencyStore.set(key, {
    state: "in_progress",
    fingerprint: fingerprint(req.body),
    createdAt: Date.now(),
  });

  // Intercept the response
  const originalJson = res.json.bind(res);
  res.json = (body) => {
    idempotencyStore.set(key, {
      state: "completed",
      fingerprint: fingerprint(req.body),
      status: res.statusCode,
      body,
      createdAt: Date.now(),
    });
    return originalJson(body);
  };

  next();
}

app.post("/api/payments", idempotency, async (req, res) => {
  const charge = await stripe.charges.create({
    amount: req.body.amount,
    currency: req.body.currency,
    customer: req.body.customer_id,
  });
  res.status(201).json({ charge_id: charge.id });
});

Three details in the middleware that matter:

The in_progress state. If two requests with the same key arrive simultaneously, the second one sees in_progress and returns 409. Without this, both could charge.

The response interception. Overriding res.json is a bit hacky, but it's the cleanest way to capture the actual response without changing every handler. In production you'd want to handle res.send and res.end too.

The fingerprint check. Catching key reuse with different bodies prevents a class of subtle bugs where the client generates a key once and uses it for multiple operations.

Expiring keys

Idempotency keys don't need to live forever. Stripe expires them after 24 hours, which is a reasonable default — it's long enough to cover any realistic retry window, and short enough that the storage doesn't grow forever.

-- Postgres cleanup job
DELETE FROM idempotency_keys
WHERE created_at < NOW() - INTERVAL '24 hours';

Or with Redis, just set a TTL when you write the key:

await redis.set(key, JSON.stringify(record), "EX", 86400);

Database schema for a real implementation

CREATE TABLE idempotency_keys (
    key             text PRIMARY KEY,
    fingerprint     text NOT NULL,
    state           text NOT NULL CHECK (state IN ('in_progress', 'completed')),
    status_code     integer,
    response_body   jsonb,
    created_at      timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX idx_idempotency_created_at ON idempotency_keys (created_at);

For high-traffic systems, put this in Redis instead of the primary database. The keys are short-lived, the reads are hot, and there's no reason to burden your main database with them.

If you use Postgres, do the insert in the same transaction as the actual operation when possible:

BEGIN;

INSERT INTO idempotency_keys (key, fingerprint, state)
VALUES ($1, $2, 'in_progress')
ON CONFLICT (key) DO NOTHING
RETURNING key;

-- If no row returned, the key exists — read and return the stored response

INSERT INTO payments (customer_id, amount) VALUES ($3, $4);

UPDATE idempotency_keys SET state = 'completed', status_code = 201, response_body = $5
WHERE key = $1;

COMMIT;

The single transaction means either both the payment and the idempotency record are committed, or neither is. If the process crashes mid-transaction, nothing happened and the client can retry safely.

What not to do

Anti-patternWhy it fails
Store only the key, not the responseCan't replay the response on retry
Store the response but not the fingerprintCan't detect key reuse with different data
No in_progress stateConcurrent requests both execute
Client-generated keys based on timestampTwo clients in the same millisecond collide
Keys based on a hash of the requestLegitimate repeat requests (buying the same item twice) collapse to one

The last two are subtler. If the client generates a key by hashing the request, two genuine purchases of the same item at the same price look like retries of one purchase. The key has to be generated per attempt, not derived from the content.

The client side

import { v4 as uuid } from "uuid";

async function createPayment(data, attempt = 1) {
  const idempotencyKey = uuid();

  try {
    const response = await fetch("/api/payments", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Idempotency-Key": idempotencyKey,
      },
      body: JSON.stringify(data),
    });

    if (response.status === 409) {
      // Another request with the same key is in progress; wait and retry
      if (attempt < 5) {
        await new Promise(r => setTimeout(r, 1000));
        return createPayment(data, attempt + 1);
      }
      throw new Error("Payment already in progress");
    }

    if (!response.ok) {
      throw new Error(`Payment failed: ${response.status}`);
    }

    return response.json();
  } catch (err) {
    // Network error — retry with the SAME key
    if (attempt < 3) {
      return createPayment(data, attempt + 1);
    }
    throw err;
  }
}

The bug I've written in the past: generating a new UUID on each retry. That defeats the whole point, because the server sees a different key each time and treats it as a new request. The key has to be captured before the retry loop, not inside it.

// Correct
async function createPayment(data) {
  const idempotencyKey = uuid();  // generated once, outside the loop

  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const response = await fetch("/api/payments", {
        method: "POST",
        headers: { "Idempotency-Key": idempotencyKey },
        body: JSON.stringify(data),
      });
      if (response.ok) return response.json();
    } catch (err) {
      if (attempt === 2) throw err;
    }
  }
}

When you need this

Any endpoint that:

  • Charges money or moves funds
  • Creates resources where duplicates are bad (orders, subscriptions)
  • Sends messages or emails
  • Triggers external side effects (webhooks, third-party API calls)

For read endpoints, it's unnecessary. For internal APIs where you control both sides, you can skip it and rely on the caller not retrying. For anything public-facing that changes state, it's the difference between a good API and a support queue.

Stripe, Square, PayPal, Adyen, and every other payment API document this feature. If you're building something that handles money or important state, you should implement it too.