All articles
Tutorials

What Is Idempotent in REST API Design?

Learn why POST requests cause duplicate charges on retry, what idempotency keys do, and how to implement server-side deduplication in Node.js with PostgreSQL.

What Is Idempotent in REST API Design? cover
12 min read

TL;DR

  • Idempotency means repeating the same operation produces the same server state as running it once.
  • POST is non-idempotent by spec, and network retries on POST /charges create duplicate charges without extra protection.
  • Idempotency keys fix this at the application layer: the client sends a UUID header, the server caches the response against it.
  • Implement deduplication with a dedicated database table and a unique constraint. The constraint handles the race condition atomically.

Your payment form has a spinner. The user clicks "Pay." The network times out after three seconds. Your frontend retries. Stripe creates two charges.

This isn't a hypothetical. Before Stripe formalized idempotency keys, this was a documented, recurring production failure, described explicitly in their 2017 engineering blog post because it was happening to real customers. The failure mode was always the same: a POST /charges that timed out, a retry that fired cleanly, and two line items on the card.

The fix isn't "don't retry." Retries are correct. Networks are unreliable. The fix is designing your API so that retrying the same logical operation never produces a different result than running it once. That property is idempotency, and it's the one most developers skip.

The fix is a server-side pattern called idempotency keys. This article explains the concept, builds the implementation in Node.js and PostgreSQL, and shows tests that prove the behavior against Stripe.

Note

What you'll need: Node.js 20+, PostgreSQL 14+, Express 4.x or 5.x (middleware examples follow 4.x style), and a Stripe test account if you run the validation suite against test mode.

Why POST retries create duplicate charges

A POST can be executed more than once without the client doing anything wrong. Common cases:

What went wrong on the wireWhat the client does next
Timeout before any response is readRetries
Success response lost in transitRetries
App crashed after send, before it saved "success" locallyRetries on restart

Same outcome: the server may see two logical attempts at one user action. None of these are client bugs.

Naive retry (no idempotency key on the request):

// Node.js 20: client-side retry without idempotency protection
async function chargeCustomer(amount, customerId) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const response = await fetch("/api/charges", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ amount, customer_id: customerId }),
      });
      return await response.json();
    } catch (err) {
      if (attempt === 3) throw err;
      await new Promise((r) => setTimeout(r, attempt * 1000));
    }
  }
}

The loop is fine. The server is the problem: every POST /charges creates a new charge. Three retries can mean three charges.

What idempotent means in REST API design

RFC 9110 (Section 9.2.2):

"A request method is considered 'idempotent' if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request."

What that implies in practice:

  • Server state, not identical response bodies. Example: first DELETE /users/42 → 204; repeat → 404. User is still gone; state matches "deleted once."
  • Semantics over mechanics: you design for what the operation means, not only what the handler does line-by-line.

Idempotent by spec: GET, HEAD, PUT, DELETE, OPTIONS, TRACE. POST is not. That is why idempotency keys exist as an application-layer pattern on top of POST.

Sequence diagram: client POST with Idempotency-Key, server checks DB, creates Stripe charge, stores response; retry after network failure returns cached 201 without Stripe

The idempotency key flow: the second request returns the cached response without touching Stripe.

On the first request the server processes the operation and stores the response against the key. Every subsequent request with that key gets the cached response back without re-running the processor.

Stripe (reference behavior):

  • Reused Idempotency-Key → same cached response, same HTTP status (e.g. cached 402 stays 402).
  • Mappings kept ≥ 24 hours; after that, the same string is a new logical request.
  • Keys scoped per account; (key, endpoint, params) fingerprint → wrong params → 400.
  • Two identical in-flight calls can surface as 409.
  • Key format: UUID v4 recommended (crypto.randomUUID() in Node); max length per Stripe docs.

Implementing idempotency keys on the server

Stack: Node.js (Express + PostgreSQL).

Step 1: Create the idempotency keys table

Dedicated table; pattern aligns with production billing key stores (see e.g. Brandur Leach on idempotency at Stripe).

migrations/001_idempotency_keys.sql
CREATE TABLE idempotency_keys (
  id              BIGSERIAL PRIMARY KEY,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  idempotency_key TEXT NOT NULL,
  last_run_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  locked_at       TIMESTAMPTZ,
  recovery_point  TEXT NOT NULL DEFAULT 'started',
  request_method  TEXT NOT NULL,
  request_path    TEXT NOT NULL,
  request_params  JSONB NOT NULL,
  response_code   INT,
  response_body   JSONB,
  user_id         BIGINT NOT NULL,
  expires_at      TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '24 hours'),
  UNIQUE(user_id, idempotency_key)
);
 
CREATE INDEX ON idempotency_keys (expires_at);
MechanismRole
UNIQUE(user_id, idempotency_key)Atomic dedupe; races resolved in the DB
recovery_pointTracks how far a multi-step operation got
locked_atAvailable for external lock tracking; not set by this implementation
Index on expires_atCheap cleanup of expired rows

Step 2: Acquire and complete keys in PostgreSQL

Database-backed, concurrency-safe acquisition:

db/idempotency.js
// Node.js 20: PostgreSQL idempotency key store (pg@8)
import pg from "pg";
import { isDeepStrictEqual } from "node:util";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
 
export async function acquireIdempotencyKey(userId, key, method, path, params) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    const requestParams = params ?? {};
 
    const insert = await client.query(
      `INSERT INTO idempotency_keys
         (user_id, idempotency_key, request_method, request_path, request_params)
       VALUES ($1, $2, $3, $4, $5)
       ON CONFLICT (user_id, idempotency_key) DO NOTHING
       RETURNING *`,
      [userId, key, method, path, JSON.stringify(requestParams)],
    );
 
    if (insert.rows.length > 0) {
      // We inserted; we own this request
      await client.query("COMMIT");
      return { status: "new", record: insert.rows[0] };
    }
 
    // Key already exists; fetch it with a lock
    const existing = await client.query(
      `SELECT * FROM idempotency_keys
       WHERE user_id = $1 AND idempotency_key = $2
       FOR UPDATE NOWAIT`,
      [userId, key],
    );
 
    await client.query("COMMIT");
    const record = existing.rows[0];
 
    // Params mismatch; reject
    if (!isDeepStrictEqual(record.request_params, requestParams)) {
      return { status: "conflict" };
    }
 
    // Already completed; return cached response
    if (record.response_code !== null) {
      return { status: "cached", record };
    }
 
    // In-flight; signal the client to retry
    return { status: "in_progress" };
  } catch (err) {
    await client.query("ROLLBACK");
    // FOR UPDATE NOWAIT throws 55P03 if the row is already locked
    if (err.code === "55P03") {
      return { status: "in_progress" };
    }
    throw err;
  } finally {
    client.release();
  }
}
 
export async function completeIdempotencyKey(
  userId,
  key,
  responseCode,
  responseBody,
) {
  await pool.query(
    `UPDATE idempotency_keys
     SET response_code = $3,
         response_body = $4,
         recovery_point = 'completed',
         last_run_at    = now()
     WHERE user_id = $1 AND idempotency_key = $2`,
    [userId, key, responseCode, JSON.stringify(responseBody)],
  );
}
BehaviorWhy
COMMIT before returning from acquisitionRow lock does not wrap your handler or Stripe
response_code === null → in_progress (409)Dedupes slow work while another request is in flight
FOR UPDATE NOWAIT → 55P03 → in_progressFail-fast when two transactions race at acquisition time

Warning

Do not read FOR UPDATE NOWAIT as “the row stays locked through payment.” After COMMIT, the row is unlocked. Concurrent requests during Stripe still see response_code === null and receive 409 in_progress; NOWAIT matters when two transactions contend during acquisition, not while the processor call is in flight.

Step 3: Express middleware

  • Validate UUID v4 Idempotency-Key; require authenticated userId.
  • cached → replay stored status + body; conflict → 400; in_progress → 409; new → wrap res.json and fire-and-forget completeIdempotencyKey so the client is not blocked.
middleware/idempotency.js
// Node.js 20: Express idempotency middleware
import {
  acquireIdempotencyKey,
  completeIdempotencyKey,
} from "../db/idempotency.js";
 
const UUID_V4 =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
 
export function idempotencyMiddleware() {
  return async (req, res, next) => {
    const key = req.headers["idempotency-key"];
 
    // Idempotency is opt-in; skip if no key
    if (!key) return next();
 
    if (!UUID_V4.test(key)) {
      return res.status(400).json({
        error: "invalid_idempotency_key",
        message: "Idempotency-Key must be a valid UUID v4.",
      });
    }
 
    const userId = req.user?.id;
    if (!userId) {
      return res.status(401).json({
        error: "unauthorized",
        message: "Authentication is required before idempotency checks.",
      });
    }
 
    const result = await acquireIdempotencyKey(
      userId,
      key,
      req.method,
      req.path,
      req.body,
    );
 
    switch (result.status) {
      case "cached":
        return res
          .status(result.record.response_code)
          .json(result.record.response_body);
 
      case "conflict":
        return res.status(400).json({
          error: "idempotency_key_reuse",
          message: "This key was used with different request parameters.",
        });
 
      case "in_progress":
        return res.status(409).json({
          error: "request_in_progress",
          message:
            "A request with this key is already being processed. Retry shortly.",
        });
 
      case "new": {
        // Intercept res.json to capture the response for storage (sync override; persistence is fire-and-forget)
        const originalJson = res.json.bind(res);
        res.json = (body) => {
          completeIdempotencyKey(userId, key, res.statusCode, body).catch(
            (err) => {
              console.error("Idempotency completion failed:", err);
            },
          );
          return originalJson(body);
        };
        return next();
      }
    }
  };
}

Step 4: Charge route and Stripe

Pass the same key to Stripe so you have app-level dedupe plus processor-level dedupe.

routes/charges.js
// Node.js 20: Express charge route with layered idempotency
import express from "express";
import Stripe from "stripe";
import { idempotencyMiddleware } from "../middleware/idempotency.js";
 
const router = express.Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
 
router.post("/charges", idempotencyMiddleware(), async (req, res) => {
  const { amount, currency, payment_method_id } = req.body;
 
  const paymentIntent = await stripe.paymentIntents.create(
    { amount, currency, payment_method: payment_method_id, confirm: true },
    // Pass your key to Stripe as a second layer of deduplication
    { idempotencyKey: req.headers["idempotency-key"] },
  );
 
  res.status(201).json({
    id: paymentIntent.id,
    status: paymentIntent.status,
    amount: paymentIntent.amount,
  });
});
 
export default router;

Verifying idempotency keys in Stripe test mode

  1. Confirm TTL, replay, and mismatch behavior against current Stripe docs for your API version.
  2. Run the API on http://localhost:3000 with TEST_TOKEN set for the test below.
  3. From project root: node --test test/idempotency.test.js
test/idempotency.test.js
// Node.js 20 built-in test runner
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import crypto from "node:crypto";
 
const BASE_URL = "http://localhost:3000";
 
async function charge(idempotencyKey, amount = 4999) {
  const res = await fetch(`${BASE_URL}/charges`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.TEST_TOKEN}`,
      "Idempotency-Key": idempotencyKey,
    },
    body: JSON.stringify({
      amount,
      currency: "usd",
      payment_method_id: "pm_card_visa", // Stripe test payment method
    }),
  });
  return { status: res.status, body: await res.json() };
}
 
describe("Idempotency middleware", () => {
  it("returns the same charge ID for duplicate keys", async () => {
    const key = crypto.randomUUID();
    const first = await charge(key);
    const second = await charge(key);
 
    assert.equal(first.status, 201);
    assert.equal(second.status, 201);
    assert.equal(first.body.id, second.body.id); // same charge, not a new one
  });
 
  it("returns 409 for concurrent duplicate requests, exactly one succeeds", async () => {
    const key = crypto.randomUUID();
    const results = await Promise.all([charge(key), charge(key), charge(key)]);
 
    const successes = results.filter((r) => r.status === 201);
    const conflicts = results.filter((r) => r.status === 409);
 
    assert.equal(successes.length, 1);
    assert.ok(conflicts.length >= 1);
    // All successful responses reference the same charge
    assert.equal(new Set(successes.map((r) => r.body.id)).size, 1);
  });
 
  it("returns 400 for parameter mismatch on same key", async () => {
    const key = crypto.randomUUID();
    await charge(key, 4999); // establish the key
    const mismatch = await charge(key, 9999); // different amount
 
    assert.equal(mismatch.status, 400);
    assert.equal(mismatch.body.error, "idempotency_key_reuse");
  });
});

Example output (timings vary):

Terminal demo: idempotency.test.js (node --test)
idempotency.test.js (node --test)
$ node --test test/idempotency.test.js
▶ Idempotency middleware
  ✔ returns the same charge ID for duplicate keys (612.884104ms)
  ✔ returns 409 for concurrent duplicate requests, exactly one succeeds (734.291205ms)
  ✔ returns 400 for parameter mismatch on same key (298.156402ms)
✔ Idempotency middleware (1648.201901ms)
ℹ tests 3
ℹ suites 1
ℹ pass 3
ℹ fail 0
ℹ duration_ms 1689.442105
TestWhat it proves
Same key, sequentialCached response path
Same key, Promise.allUNIQUE + acquisition race + in_progress while work runs
Same key, different amountParameter fingerprint → 400

When not to use idempotency keys

SituationReason
GET / PUT / DELETE etc. are already spec-idempotentKeys add cost with no gain
POST only mutates your DB, no payments/email/webhooksA business unique key (order_number, …) is often simpler
Analytics / audit / high-volume append-only POST /eventsYou usually want every event, not dedupe
Retry window longer than your key TTL (often 24h-style)Same key string becomes a new operation after expiry: extend TTL or dedupe by business id

How other APIs handle idempotency keys

ProviderTypical headerCheck in docs
StripeIdempotency-KeyStatus replay, TTL, mismatch vs in-flight
PayPalPayPal-Request-IdOmitting header may still process duplicates
AdyenIdempotency-KeyLength limits, replay rules vs Stripe
  • Conditional requests (ETag / If-Match) on some APIs (e.g. GitHub REST) solve concurrency, not the same problem as payment POST keys. Same family of “safe retries,” different tool.
  • IETF Idempotency-Key header draft. Until it is widely adopted, assert your provider’s contract in tests, not a generic table row.

Frequently asked questions

Share𝕏

Writer

  • Wale Bashir

    Technical content writer and full-stack engineer with experience across Web3, AI, and backend systems.

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
What Is Idempotent in REST API Design? | Reclear