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.
| Method | Idempotent? | Why |
|---|---|---|
| GET | โ Yes | Reading data doesnโt change state |
| PUT | โ Yes | Replaces the entire resource โ same input, same result |
| DELETE | โ Yes | Deleting something twice still means itโs deleted |
| PATCH | โ ๏ธ Depends | Can be idempotent if it sets absolute values, not if it increments |
| POST | โ No | Creates 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:
- Client sends
POST /paymentswith a $50 charge - The server processes the payment successfully
- The response is lost due to a network timeout
- The client retries the same
POST /paymentsrequest - 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:
- Client generates a UUID and attaches it as
Idempotency-Key - Server checks if it has seen this key before
- First time: process the request, store the key + response
- 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
setexwith 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-Keyheader - 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.