๐Ÿ› ๏ธ Developer Tools
ยท 6 min read

Idempotency in APIs โ€” Why It Matters and How to Implement It (2026)


A user clicks โ€œPay Now.โ€ The request times out. They click again. Now theyโ€™ve been charged twice. This isnโ€™t a hypothetical โ€” itโ€™s one of the most common bugs in production APIs, and itโ€™s entirely preventable with idempotency.

What Is Idempotency?

Idempotency means that making the same request multiple times produces the same result as making it once. If you send an identical API call two, three, or fifty times, the server treats it as a single operation. No duplicate side effects, no corrupted data.

This concept comes from mathematics โ€” applying a function multiple times yields the same result as applying it once. In the context of REST APIs, itโ€™s a critical property for building reliable systems that survive network failures, client retries, and load balancer timeouts.

Which HTTP Methods Are Naturally Idempotent?

Not all HTTP methods behave the same way. Understanding which ones are inherently idempotent shapes how you design your APIs.

MethodIdempotent?Why
GETโœ… YesReading data doesnโ€™t change state
PUTโœ… YesReplaces the entire resource โ€” same input, same result
DELETEโœ… YesDeleting something twice still means itโ€™s deleted
PATCHโš ๏ธ DependsCan be idempotent if it sets absolute values, not if it increments
POSTโŒ NoCreates a new resource each time โ€” this is where problems live

POST is the troublemaker. Every retry creates another record, triggers another payment, sends another email. Thatโ€™s why idempotency keys exist.

The Double-Charge Problem

Hereโ€™s the scenario that keeps backend engineers up at night:

  1. Client sends POST /payments with a $50 charge
  2. The server processes the payment successfully
  3. The response is lost due to a network timeout
  4. The client retries the same POST /payments request
  5. The server processes it again โ€” the customer is now charged $100

This applies to any state-changing operation: creating orders, sending notifications, transferring funds, provisioning resources. Without idempotency, retries are dangerous. With it, theyโ€™re safe.

Idempotency Keys: The Stripe Approach

Stripe popularized the pattern thatโ€™s now an industry standard: the client generates a unique key and sends it with the request via an Idempotency-Key header.

The flow works like this:

  1. Client generates a UUID and attaches it as Idempotency-Key
  2. Server checks if it has seen this key before
  3. First time: process the request, store the key + response
  4. Retry: return the stored response without re-processing

Hereโ€™s a Python implementation using FastAPI and Redis:

import uuid, json, redis
from fastapi import FastAPI, Header, HTTPException

app = FastAPI()
cache = redis.Redis()
IDEMPOTENCY_TTL = 86400  # 24 hours

@app.post("/payments")
async def create_payment(
    body: dict,
    idempotency_key: str = Header(alias="Idempotency-Key")
):
    cached = cache.get(f"idem:{idempotency_key}")
    if cached:
        return json.loads(cached)

    # Process the payment
    result = {"id": str(uuid.uuid4()), "amount": body["amount"], "status": "completed"}

    cache.setex(f"idem:{idempotency_key}", IDEMPOTENCY_TTL, json.dumps(result))
    return result

And the equivalent in Node.js with Express:

const express = require("express");
const Redis = require("ioredis");
const { v4: uuidv4 } = require("uuid");

const app = express();
const redis = new Redis();
const TTL = 86400;

app.use(express.json());

app.post("/payments", async (req, res) => {
  const key = req.headers["idempotency-key"];
  if (!key) return res.status(400).json({ error: "Idempotency-Key header required" });

  const cached = await redis.get(`idem:${key}`);
  if (cached) return res.json(JSON.parse(cached));

  const result = { id: uuidv4(), amount: req.body.amount, status: "completed" };

  await redis.setex(`idem:${key}`, TTL, JSON.stringify(result));
  res.json(result);
});

The client side is straightforward โ€” generate a key per logical operation and include it in the header:

import requests, uuid

idempotency_key = str(uuid.uuid4())

response = requests.post(
    "https://api.example.com/payments",
    json={"amount": 5000, "currency": "usd"},
    headers={"Idempotency-Key": idempotency_key}
)

If the request fails and the client retries with the same key, the server returns the original response. No double charge.

Database-Level Idempotency

Idempotency keys in Redis handle the application layer, but you should also enforce uniqueness at the database level as a safety net. This is defense in depth โ€” if your cache fails or gets evicted, the database catches duplicates.

Unique Constraints

Add a unique constraint on the idempotency key column in your PostgreSQL table:

CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    idempotency_key VARCHAR(255) UNIQUE NOT NULL,
    amount INTEGER NOT NULL,
    status VARCHAR(50) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

Now if a duplicate slips through, the database rejects it with a constraint violation that you can catch and handle gracefully:

from psycopg2 import errors

try:
    cursor.execute(
        "INSERT INTO payments (idempotency_key, amount, status) VALUES (%s, %s, %s) RETURNING *",
        (idempotency_key, amount, "completed")
    )
except errors.UniqueViolation:
    cursor.execute("SELECT * FROM payments WHERE idempotency_key = %s", (idempotency_key,))
    return cursor.fetchone()  # Return existing record

Upserts

For operations where you want to update-or-create in a single atomic statement, use ON CONFLICT:

INSERT INTO payments (idempotency_key, amount, status)
VALUES ('abc-123', 5000, 'completed')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING *;

This is cleaner than try/catch logic and avoids race conditions entirely.

TTL: When to Expire Idempotency Records

Idempotency records shouldnโ€™t live forever. You need a TTL (time-to-live) strategy:

  • Redis cache: Set a TTL of 24โ€“48 hours. This covers retry windows for most client implementations. The examples above use setex with an 86400-second (24-hour) expiry.
  • Database records: Keep these longer for audit trails. Use a background job to clean up records older than 30โ€“90 days, or partition the table by month.
  • Too short: If the TTL is shorter than the clientโ€™s retry window, duplicates can slip through.
  • Too long: Storage costs grow and lookups slow down if you never clean up.

A common pattern is to use Redis as the fast-path check (short TTL) and the database as the durable store (long TTL). The Redis lookup is O(1) and keeps your API response times low.

Edge Cases to Handle

A production-ready implementation needs to account for a few more scenarios:

  • Concurrent requests with the same key: Use a distributed lock (Redis SET NX) to ensure only one request processes at a time. The second request should wait or return 409 Conflict.
  • Failed requests: If the original request failed, the retry should re-process โ€” donโ€™t cache error responses (or cache them with a shorter TTL so the client can retry).
  • Different payloads, same key: If a client sends the same idempotency key with a different request body, return 422 Unprocessable Entity. The key is tied to a specific operation.
  • Key format: UUIDs (v4) are the standard. Some teams use deterministic keys based on the operation (e.g., user_123_order_456) for automatic deduplication without client-side key generation.

Quick Checklist

Before shipping your API, verify:

  • POST endpoints accept an Idempotency-Key header
  • First request: process and store key + response
  • Retry: return cached response, skip processing
  • Database has a unique constraint on the idempotency key
  • TTL is set on cached records (24h is a good default)
  • Concurrent duplicate requests are handled (locking or 409)
  • Mismatched payloads for the same key return an error
  • Error responses are not cached (or cached with short TTL)

Wrapping Up

Idempotency isnโ€™t optional for any API that handles money, creates resources, or triggers side effects. The pattern is simple: generate a unique key on the client, check for it on the server, and return cached responses on retries. Layer in database constraints as a safety net, set reasonable TTLs, and handle edge cases like concurrent requests.

Stripe, PayPal, and AWS all use this pattern. If your API accepts POST requests that change state, you should too.

For more on building robust APIs, check out the API design best practices guide and the error handling guide.