Idempotency Keys: How to Make Your API Calls Safe to Retry
Network failures happen. A request times out, a connection drops, a load balancer restarts mid-flight. The question isn't whether you'll face this — it's what happens when your client retries and the server already processed the first attempt.
For read-only requests (GET, HEAD), this is painless: retry as many times as you like. But for write operations — charging a card, creating an order, sending an email — retrying a request that already succeeded can cause real damage: double charges, duplicate orders, or a customer's inbox flooded with the same confirmation email.
Idempotency keys are the standard solution. They're simple, widely supported, and often misunderstood.
What Is an Idempotency Key?
An idempotency key is a unique string the client sends with a write request. The server uses it to deduplicate: if it sees the same key twice, it returns the cached result from the first successful call instead of executing the operation again.
The client generates the key (usually a UUID), and the server stores it alongside the result for a window of time (commonly 24 hours).
Stripe popularised this pattern with their Idempotency-Key header, and it's now used by Stripe, Braintree, Adyen, Square, and many other payment and infrastructure APIs.
Implementing It Server-Side (Node.js Example)
Here's a minimal Express implementation that stores idempotency keys in Redis:
const express = require('express');
const redis = require('redis');
const { v4: uuidv4 } = require('uuid');
const app = express();
const client = redis.createClient();
app.use(express.json());
app.post('/charges', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({ error: 'Idempotency-Key header is required' });
}
// Check for a cached result
const cached = await client.get(`idem:${idempotencyKey}`);
if (cached) {
return res.status(200).json(JSON.parse(cached));
}
// Process the charge
const result = { id: uuidv4(), amount: req.body.amount, status: 'succeeded' };
// Cache the result for 24 hours
await client.setEx(`idem:${idempotencyKey}`, 86400, JSON.stringify(result));
res.status(201).json(result);
});
Key things to note: the key is validated before processing, and the result is stored after success. If the operation fails, don't cache — the client should be able to retry with the same key after fixing the error.
Sending Idempotency Keys Client-Side (Python)
On the client side, generate a stable key per logical operation — not per HTTP attempt:
import uuid
import httpx
from tenacity import retry, stop_after_attempt, wait_exponential
# Generate ONCE per logical operation, persist if needed
idempotency_key = str(uuid.uuid4())
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def create_charge(amount_cents: int) -> dict:
response = httpx.post(
"https://api.example.com/charges",
json={"amount": amount_cents, "currency": "usd"},
headers={"Idempotency-Key": idempotency_key},
timeout=10.0,
)
response.raise_for_status()
return response.json()
# All three attempts use the same key — only one charge is created
charge = create_charge(2000)
The mistake to avoid: generating a new key on each retry. If the first request succeeded but the response was lost in transit, a new key triggers a second charge.
What to Watch Out For
A few edge cases that bite teams in production:
Conflicting payloads: If a client sends the same key with different request bodies, the server should return a 422 Unprocessable Entity or 409 Conflict — not silently use the first result. Always include a payload hash check.
Key collisions: Use UUID v4, not sequential IDs or timestamps. The chance of a collision over 24 hours is astronomically low with proper UUIDs.
Scope: Keys should be scoped to an endpoint or resource type. A key used for /charges shouldn't be reusable on /refunds.
TTL expiry: After the key expires, a replay of the request is a brand new operation. Make sure your clients understand this window.
Testing This Without a Live Server
When building and testing idempotency logic, it's useful to fire the same request multiple times and inspect the response headers and bodies side-by-side. APIKumo makes this straightforward — you can save the request with the idempotency key set as a header variable, run it back-to-back, and compare responses in the history view to confirm the server is deduplicating correctly. No scripting required.
Idempotency keys are one of those patterns that seem optional until something goes wrong at 2am. Bake them in from day one.
Top comments (0)