DEV Community

Mean for APIKumo

Posted on

Idempotency Keys: The Simple Trick That Prevents Duplicate Payments and Ghost Orders

Idempotency Keys: The Simple Trick That Prevents Duplicate Payments and Ghost Orders

You've seen it happen. A user clicks "Place Order," the network hiccups, the request times out, and now they've been charged twice. Or the charge went through but the order never appeared. Neither outcome is acceptable, and both are preventable — with idempotency keys.

What Is an Idempotency Key?

An idempotency key is a unique identifier you attach to a request so that the server can safely process it exactly once, no matter how many times the client retries. If the server receives the same key again, it returns the original response instead of executing the operation a second time.

The concept is borrowed from mathematics: an operation is idempotent if applying it multiple times produces the same result as applying it once. DELETE /users/42 is naturally idempotent — deleting an already-deleted user changes nothing. POST /orders is not — sending it twice creates two orders.

Idempotency keys make non-idempotent operations safe to retry.

How It Works in Practice

The client generates a unique key (typically a UUID v4) before sending the request. It attaches the key as a header, usually Idempotency-Key. The server stores the key and the response in a short-lived cache (24 hours is common). On retry, the server finds the key, skips execution, and returns the stored response.

Here's a payment request using this pattern in Python:

import uuid
import httpx

def charge_customer(customer_id: str, amount_cents: int) -> dict:
    idempotency_key = str(uuid.uuid4())  # generate once, store for retries

    headers = {
        "Authorization": "Bearer sk_live_...",
        "Idempotency-Key": idempotency_key,
        "Content-Type": "application/json",
    }

    payload = {
        "customer": customer_id,
        "amount": amount_cents,
        "currency": "usd",
    }

    for attempt in range(3):
        try:
            response = httpx.post(
                "https://api.stripe.com/v1/charges",
                headers=headers,   # same key on every retry
                json=payload,
                timeout=10,
            )
            response.raise_for_status()
            return response.json()
        except (httpx.TimeoutException, httpx.ConnectError):
            if attempt == 2:
                raise
            continue
Enter fullscreen mode Exit fullscreen mode

The critical detail: the idempotency_key is generated once before the loop, not inside it. Every retry sends the identical key, so the server knows they're all the same request.

Implementing the Server Side

If you're building an API that accepts payments, bookings, or any state-changing operation, you need to store and check idempotency keys yourself.

import { randomUUID } from "crypto";
import { redis } from "./redis";

async function handleCharge(req: Request): Promise<Response> {
  const key = req.headers.get("Idempotency-Key");

  if (!key) {
    return Response.json({ error: "Idempotency-Key header required" }, { status: 400 });
  }

  // Check for a cached response
  const cached = await redis.get(`idem:${key}`);
  if (cached) {
    return Response.json(JSON.parse(cached), { status: 200 });
  }

  // Execute the operation
  const result = await processCharge(req);

  // Store result for 24 hours
  await redis.set(`idem:${key}`, JSON.stringify(result), "EX", 86400);

  return Response.json(result, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

A few implementation notes worth keeping in mind:

Scope keys to the user. Store the key as user:{userId}:idem:{key} to prevent one user from accidentally (or maliciously) colliding with another's key.

Don't cache errors. If the operation fails with a 500, don't store the result — let the client retry with the same key and give the operation another chance. Do cache 4xx errors, since a bad request won't succeed on retry.

Return the same status code. If the original response was 201 Created, the cached response should also be 201, not 200. Clients sometimes branch on status codes.

When to Require Idempotency Keys

Not every endpoint needs them. GET requests are already safe to repeat. Natural idempotent operations (DELETE, PUT with full resource replacement) don't need extra help. But you should require idempotency keys for:

  • Payment and refund creation
  • Order placement
  • Email or SMS sends
  • Any operation that creates a resource or triggers a side effect

Stripe, Braintree, and Adyen all require idempotency keys on charge endpoints for exactly this reason. If you're building a public API, following their lead protects your users and your data integrity.

Testing Idempotency

Before shipping, verify the behaviour explicitly in your test suite:

def test_duplicate_charge_is_ignored():
    key = str(uuid.uuid4())
    first  = client.post("/charges", headers={"Idempotency-Key": key}, json=payload)
    second = client.post("/charges", headers={"Idempotency-Key": key}, json=payload)

    assert first.status_code == 201
    assert second.status_code == 201
    assert first.json()["id"] == second.json()["id"]  # same charge, not a new one
    assert db.count_charges() == 1                    # only one record created
Enter fullscreen mode Exit fullscreen mode

Idempotency keys are one of those small API design choices with an outsized reliability impact. They're cheap to implement and expensive to skip — your support team (and your users) will thank you.

If you want to test idempotency key behaviour against your API without writing scaffolding from scratch, APIKumo lets you set custom headers like Idempotency-Key per-request or as collection-level defaults, so you can replay the same request with identical headers and verify your server handles duplicates correctly. It's a quick sanity check before you ship.

Top comments (0)