DEV Community

Mean for APIKumo

Posted on

Idempotency Keys: The API Pattern That Saves You From Duplicate Payments and Phantom Records

Idempotency Keys: The API Pattern That Saves You From Duplicate Payments and Phantom Records

Your user clicks "Pay" and nothing happens. So they click again. Your server processes both requests — and charges the card twice.

This is one of the most damaging bugs you can ship, and it's entirely preventable. The solution has been hiding in plain sight in Stripe's API since 2013: idempotency keys.

What Is an Idempotency Key?

An idempotency key is a unique token the client sends with a mutating request (POST, PATCH, DELETE). The server uses it to guarantee that no matter how many times you send the same request — due to retries, network failures, or impatient button-clicks — the operation only executes once.

The word comes from mathematics: a function f is idempotent if f(f(x)) = f(x). Applying it twice is the same as applying it once.

Stripe implements it like this:

POST /v1/charges
Authorization: Bearer sk_live_...
Idempotency-Key: charge_user_42_order_891
Content-Type: application/json

{
  "amount": 2000,
  "currency": "usd",
  "source": "tok_visa"
}
Enter fullscreen mode Exit fullscreen mode

If the client sends this request again with the same Idempotency-Key, Stripe returns the cached response from the first execution — no second charge.

Implementing Idempotency Keys in Your Own API

Here's a minimal implementation in Node.js with Express and Redis:

const express = require('express');
const redis = require('redis');
const { v4: uuidv4 } = require('uuid');

const app = express();
const cache = redis.createClient();
const IDEMPOTENCY_TTL = 86400; // 24 hours in seconds

app.use(express.json());

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

  // Only applies to mutating methods
  if (!['POST', 'PATCH', 'DELETE'].includes(req.method) || !key) {
    return next();
  }

  const cacheKey = `idempotency:${key}`;
  const cached = await cache.get(cacheKey);

  if (cached) {
    const { statusCode, body } = JSON.parse(cached);
    return res.status(statusCode).json(body);
  }

  // Intercept the response to cache it
  const originalJson = res.json.bind(res);
  res.json = async (body) => {
    if (res.statusCode < 500) {
      await cache.setEx(
        cacheKey,
        IDEMPOTENCY_TTL,
        JSON.stringify({ statusCode: res.statusCode, body })
      );
    }
    return originalJson(body);
  };

  next();
}

app.use(idempotencyMiddleware);

app.post('/charges', async (req, res) => {
  // Your actual charge logic here
  const charge = await processCharge(req.body);
  res.status(201).json({ id: charge.id, status: 'succeeded' });
});
Enter fullscreen mode Exit fullscreen mode

Key design decisions baked in:

  • Only mutating methods get the idempotency treatment. GET requests are already safe to retry.
  • 500 errors are never cached. A server crash shouldn't lock the client into a permanent error.
  • 24-hour TTL. Long enough to cover any reasonable retry window, short enough to not bloat your cache forever.

Generating Idempotency Keys on the Client

The key should be unique per logical operation, not per HTTP request. Here's a pattern that works:

import uuid
import hashlib

def make_idempotency_key(user_id: str, operation: str, resource_id: str) -> str:
    """
    Deterministic key: same inputs always produce the same key.
    Safe to call multiple times before sending.
    """
    raw = f"{user_id}:{operation}:{resource_id}"
    return hashlib.sha256(raw.encode()).hexdigest()[:32]

# Example usage
key = make_idempotency_key(
    user_id="user_42",
    operation="charge",
    resource_id="order_891"
)
# → always produces the same key for this user + order combination

import httpx

response = httpx.post(
    "https://api.example.com/charges",
    headers={"Idempotency-Key": key},
    json={"amount": 2000, "currency": "usd"}
)
Enter fullscreen mode Exit fullscreen mode

Using a deterministic hash instead of a random UUID means the client can reconstruct the key after a crash, without needing to store it separately.

What About Conflicts?

What if the same idempotency key arrives with a different request body? This indicates a client bug — not a legitimate retry. Return a 422 Unprocessable Entity:

async function idempotencyMiddleware(req, res, next) {
  const key = req.headers['idempotency-key'];
  if (!key) return next();

  const cacheKey = `idempotency:${key}`;
  const cached = await cache.get(cacheKey);

  if (cached) {
    const stored = JSON.parse(cached);
    const bodyHash = hashBody(req.body);

    if (stored.bodyHash !== bodyHash) {
      return res.status(422).json({
        error: 'idempotency_key_reuse',
        message: 'This idempotency key was used with a different request body.'
      });
    }

    return res.status(stored.statusCode).json(stored.body);
  }

  // Store body hash alongside the response
  req.idempotencyBodyHash = hashBody(req.body);
  next();
}

function hashBody(body) {
  return require('crypto')
    .createHash('sha256')
    .update(JSON.stringify(body))
    .digest('hex');
}
Enter fullscreen mode Exit fullscreen mode

The Header Name Convention

There's no RFC standard here yet, but the de facto conventions are:

Header Used by
Idempotency-Key Stripe, Adyen, many fintech APIs
X-Idempotency-Key Some older APIs
X-Request-ID Often used for tracing, sometimes idempotency

Stick with Idempotency-Key for new APIs — it's become the community standard.

Testing Idempotency in Your API Client

Before shipping idempotency support, you want to verify that duplicate requests actually return the same cached response. This is where having a good API testing environment matters enormously. With APIKumo, you can create a collection with pre-request scripts that automatically generate and re-use idempotency keys across retried requests — making it straightforward to simulate retry scenarios and confirm your server behaves correctly, without writing a separate test harness.

Wrapping Up

Idempotency keys are a small header with enormous consequences. They're the difference between a payment API users trust and one that occasionally charges customers twice. The implementation is under 50 lines of middleware, the client-side change is a single header, and the protection they provide is permanent.

If you're building any API that processes money, creates records, or sends messages — idempotency keys should be non-negotiable.

Top comments (0)