Idempotency keys: making POST requests safe to retry
Networks fail halfway through requests. Here is how idempotency keys let clients retry payments, orders and other writes without creating duplicates, with a working FastAPI pattern.
A client sends POST /orders. The server creates the order, but the response is lost because of a timeout, a dropped mobile connection or a load balancer restart. The client doesn't know whether the order exists, so it retries. Now the customer has two orders and two charges.
GET, PUT and DELETE are meant to be idempotent: repeating them leaves the system in the same state. POST is not. Idempotency keys close that gap, and payment APIs such as Stripe's have used them for years.
The idea in one paragraph
The client generates a unique key (a UUID) for each logical operation and sends it in a header such as Idempotency-Key. The server records the key with the result of the first request. If the same key arrives again, the server returns the stored result instead of doing the work twice. A retry becomes harmless.
POST /orders HTTP/1.1
Idempotency-Key: 6f1c2a8e-4b1d-4f0a-9a57-1d2f3e4c5b6a
Content-Type: application/json
{"sku": "BOOK-42", "qty": 1}
What the server has to store
For each key you need:
| Field | Why |
|---|---|
| key | the lookup |
| client or user id | keys are only unique per client |
| request fingerprint | a hash of method, path and body, to catch key reuse with a different payload |
| status | in_progress or completed |
| response code and body | what to replay |
| created_at | for expiry (24 hours is common) |
A minimal FastAPI implementation
The important parts are the unique constraint and handling the in-progress case, where two copies of the same request arrive at nearly the same moment.
import hashlib, json
from fastapi import FastAPI, Header, HTTPException, Request
from fastapi.responses import JSONResponse
app = FastAPI()
def fingerprint(request: Request, body: bytes) -> str:
return hashlib.sha256(request.method.encode() + request.url.path.encode() + body).hexdigest()
@app.post("/orders")
async def create_order(request: Request, idempotency_key: str = Header(...)):
body = await request.body()
fp = fingerprint(request, body)
user_id = current_user_id(request)
# Claim the key. A UNIQUE(user_id, key) constraint makes this atomic.
claimed = db.try_insert_key(user_id, idempotency_key, fp, status="in_progress")
if not claimed:
row = db.get_key(user_id, idempotency_key)
if row.fingerprint != fp:
raise HTTPException(422, "Idempotency-Key reused with a different request")
if row.status == "in_progress":
raise HTTPException(409, "A request with this key is still being processed")
return JSONResponse(json.loads(row.body), status_code=row.code)
try:
order = place_order(user_id, json.loads(body))
result, code = {"id": order.id, "status": "created"}, 201
except ValidationError as e:
result, code = {"error": str(e)}, 400
db.complete_key(user_id, idempotency_key, code, json.dumps(result))
return JSONResponse(result, status_code=code)
Design decisions worth calling out:
- Insert first, then do the work. Checking "does the key exist?" and then inserting leaves a race window. Let the database's unique constraint decide who wins.
- Store failures too, carefully. A
400caused by bad input should be replayed, because retrying won't change it. A500caused by a crash should usually release the key so the client can retry. - Reject reuse with a different body. A client bug that reuses a key for a different order should fail loudly, not silently return the first order.
- Expire keys. Clean up rows older than your retry window with a scheduled job.
Make the side effects idempotent too
The key protects your API boundary. Downstream effects need the same care:
- When you call a payment provider, pass your own idempotency key through.
- When you publish an event, include a unique event id so consumers can drop duplicates.
- Database writes can often use natural uniqueness, such as
UNIQUE(order_id, line_no).
Client-side rules
- Generate the key once per user action, not once per HTTP attempt.
- Retry with backoff on timeouts, connection errors,
409and5xx. - Do not retry on
4xxvalidation errors; fix the request instead.
When you need it
Any endpoint where a duplicate is costly: payments, orders, sending emails or SMS, provisioning resources, transferring credits. It costs a table and a few dozen lines of code, and it turns "did my request go through?" from a support ticket into a non-event.
Written by RecallRun Editors for the RecallRun community. Community posts are checked for safety and reviewed by our editors before publishing, but the views and claims are the author's own. Links are the author's; open them with care. Report this post.
More from the community
- Tech articles
Caching for backend engineers: cache-aside, TTLs and the stampede problem
The caching patterns you will actually use, how to pick TTLs, how to invalidate safely, and how to stop a cache miss from turning into a thundering herd on your database.
- Tools
Pydantic v2: validate data at the edges of your Python application
Pydantic turns type hints into fast runtime validation and serialisation. Here are the core patterns for API payloads, settings and LLM outputs, plus the v2 changes that trip people up.
- Tech articles
Vector embeddings explained for developers: similarity, normalisation and the mistakes that hurt search
What an embedding really is, how cosine similarity and dot product relate, why you should normalise, and the practical mistakes that quietly make semantic search worse.
Share a tech article or a tool you built. Every post is checked and reviewed before it goes live.