Idempotency Keys: The API Safety Net You Probably Aren't Using
Network failures happen at the worst possible times. A user clicks "Pay Now," your request hits the payment API, the connection drops before the response arrives — and now you don't know if the charge went through. Do you retry and risk a double charge? Do you let it fail and frustrate the user?
Idempotency keys solve this problem cleanly. They're one of those API patterns that seems like a small detail until the moment you desperately need it, and then they feel like magic.
What Is an Idempotency Key?
An idempotency key is a unique value you generate on the client side and attach to a request. The server uses it to de-duplicate: if it sees the same key twice, it returns the cached result of the first request rather than executing the operation again.
The practical effect is that retrying a failed request is always safe. The operation runs once — exactly once — no matter how many times you send it.
The Classic Problem Without Idempotency
import requests
def charge_customer(customer_id: str, amount_cents: int) -> dict:
response = requests.post(
"https://api.payments.example/charges",
json={"customer_id": customer_id, "amount": amount_cents},
headers={"Authorization": "Bearer sk_live_..."}
)
response.raise_for_status()
return response.json()
# If this times out after the server processed it, calling it again = double charge
charge_customer("cus_abc123", 4999)
If raise_for_status() throws a ConnectionError or a timeout, you have no idea whether the charge happened. You're stuck.
Adding Idempotency Keys
Most APIs that care about this (Stripe, Brex, Adyen) accept idempotency keys via a header:
import uuid
import requests
from typing import Optional
def charge_customer_safely(
customer_id: str,
amount_cents: int,
idempotency_key: Optional[str] = None
) -> dict:
# Generate a stable key for this specific operation
key = idempotency_key or str(uuid.uuid4())
response = requests.post(
"https://api.payments.example/charges",
json={"customer_id": customer_id, "amount": amount_cents},
headers={
"Authorization": "Bearer sk_live_...",
"Idempotency-Key": key,
}
)
response.raise_for_status()
return response.json()
Now you can retry freely:
import time
key = str(uuid.uuid4()) # Generate ONCE, reuse on retries
max_retries = 3
for attempt in range(max_retries):
try:
result = charge_customer_safely("cus_abc123", 4999, idempotency_key=key)
print(f"Charged successfully: {result['id']}")
break
except requests.exceptions.Timeout:
if attempt == max_retries - 1:
raise
time.sleep(2 ** attempt) # Exponential backoff
The key insight: the key is generated before the first attempt, not per-attempt. Reusing the same key across retries is what makes this safe.
Implementing Idempotency on Your Own API
If you're building an API, adding idempotency support is worth the effort. Here's a minimal implementation in Node.js with Express and Redis:
const express = require('express');
const redis = require('redis');
const app = express();
const client = redis.createClient();
app.use(express.json());
async function idempotencyMiddleware(req, res, next) {
const key = req.headers['idempotency-key'];
if (!key) return next(); // Key is optional — skip if absent
const cached = await client.get(`idem:${key}`);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
// Intercept the response to cache it
const originalJson = res.json.bind(res);
res.json = async (body) => {
await client.setEx(
`idem:${key}`,
86400, // Cache for 24 hours
JSON.stringify({ status: res.statusCode, body })
);
return originalJson(body);
};
next();
}
app.post('/charges', idempotencyMiddleware, async (req, res) => {
// Your actual charge logic here
const charge = await processCharge(req.body);
res.status(201).json(charge);
});
A few rules to enforce:
- Scope keys to the authenticated user. User A's key should never collide with User B's.
- Return 422 if the same key is reused with different request bodies. Mismatched payloads suggest a bug in the client.
- Set a reasonable TTL. 24 hours is standard; some APIs go up to 7 days.
What Makes a Good Idempotency Key?
Keys should be:
- Unique per logical operation — not per HTTP request
- Cryptographically random — UUID v4 is the standard choice
- Stable under retries — generate once, persist it until the operation succeeds
A common mistake is generating a new UUID on every retry. That completely defeats the purpose.
// ❌ Wrong — new key per attempt
for (let i = 0; i < 3; i++) {
await fetch('/charges', {
headers: { 'Idempotency-Key': crypto.randomUUID() } // New key each time!
});
}
// ✅ Correct — stable key across all retries
const key = crypto.randomUUID(); // Generated once
for (let i = 0; i < 3; i++) {
await fetch('/charges', {
headers: { 'Idempotency-Key': key } // Reused across retries
});
}
Which Endpoints Need Idempotency?
Not all operations need this treatment. A good rule of thumb:
-
Yes:
POST /charges,POST /orders,POST /transfers— anything that moves money or creates unique records - Yes: Any webhook delivery retry mechanism
-
No:
GETandDELETE— these are already naturally idempotent -
Judgment call:
PUT— it's idempotent by definition in REST, but explicit keys don't hurt for extra safety
Testing Idempotent Endpoints
Verify your implementation handles three cases:
KEY="test-key-$(date +%s)"
# First request — should process normally
curl -X POST https://api.example.com/charges \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 4999}' \
| jq '.id' # Note the returned ID
# Second request with same key — should return identical response
curl -X POST https://api.example.com/charges \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 4999}' \
| jq '.id' # Should be the same ID
# Same key, different body — should return 422
curl -X POST https://api.example.com/charges \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"amount": 9999}' \
| jq '.error'
If your API passes all three, your idempotency implementation is solid.
Idempotency keys are one of those features that's invisible when it works and catastrophic when it's missing. If you're working with payment flows, order creation, or any operation your users would be upset to see run twice, add idempotency support now — before the production incident.
If you want to test how your idempotent endpoints behave end-to-end, APIKumo makes it straightforward to craft requests with custom headers, save idempotency keys as environment variables, and replay the same request multiple times to verify your de-duplication logic holds up. It's the kind of sanity check that's easy to skip and painful to skip.
Top comments (0)