What makes an API endpoint idempotent, and how do idempotency keys work for non-idempotent operations like payments?
Idempotency is what lets a client retry blindly after a timeout without double-charging anyone: the guarantee HTTP gives you for free, and the one you have to build yourself.
LEARN BY DOINGPut your intuition to work3 steps
What makes an API endpoint idempotent, and how do idempotency keys work for non-idempotent operations like payments?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Idempotent, safe, and why the difference matters
An operation is idempotent if performing it once produces the same server-side result as performing it two, ten, or a thousand times with identical input. It says nothing about whether the first call has a side effect — DELETE /users/42 absolutely changes state the first time — it only promises that repeating the exact same request afterward doesn't compound that effect. That's a narrower guarantee than safe (no side effects at all, like GET), and it's easy to conflate the two. PUT /users/42 with a full representation is idempotent (replacing a resource with the same value twice leaves it exactly as replacing it once would) but it is not safe (it does write).
| Method | Safe | Idempotent | Why |
|---|---|---|---|
GET, HEAD, OPTIONS | Yes | Yes | Read-only by definition |
PUT | No | Yes | Full replacement — applying the same representation twice is a no-op the second time |
DELETE | No | Yes | Deleting an already-deleted resource still results in "not present" |
POST | No | No | Creates a new resource by default — calling it twice creates two |
PATCH | No | Depends | A full-replace patch is idempotent; {"op": "increment", "amount": 5} is not |
#Where this breaks in the real world
HTTP gives you the idempotency guarantee for PUT and DELETE for free — the server is responsible for implementing them that way, and if it does, a client can retry blindly after a timeout with zero risk. The problem is that the single most retry-prone operation in a typical backend — "create a thing" — is a POST, and POST carries no such guarantee. Nowhere is this more dangerous than payments: a client sends POST /payments, the card is charged, and the response is lost to a network blip before it reaches the client. From the client's point of view, the request might have failed. The only safe instinct — retry — now risks charging the customer twice for the exact same purchase.
#Idempotency keys: making POST behave
An idempotency key is a client-generated, unique token — typically a UUID — sent as a header (Idempotency-Key) alongside a naturally non-idempotent request. The server's contract becomes: "the first time I see this key, I'll actually perform the operation and remember the result; every subsequent request with the same key gets that same stored result back, without touching the world again." The client is responsible for reusing the exact same key on retries of the exact same logical operation, and generating a fresh key for a genuinely new one.
The server side needs three things to do this correctly:
- Atomic reservation. Two requests with the same key can arrive concurrently (a client that times out and retries immediately, racing its own original request). The server must atomically claim the key before doing any work — an
INSERT ... ON CONFLICT DO NOTHINGin Postgres, or aSET key value NXin Redis — so exactly one caller proceeds and the other is told to wait or gets the eventual result. - Payload fingerprinting. Store a hash of the request body alongside the key. If the same key arrives with a different body, that's a client bug (or a hostile actor), not a legitimate retry — reject it rather than silently returning a stale result for the wrong request.
- A response cache with a sane TTL. Store the status code and body of the completed operation so retries can replay it verbatim, and expire it after a bounded window (24-72 hours is typical) rather than forever, since idempotency keys are meant to cover a client's retry window, not become a permanent ledger.
import jsonimport hashlibimport redisfrom flask import Flask, request, jsonifyapp = Flask(__name__)r = redis.Redis(host="localhost", port=6379, db=0)IDEMPOTENCY_TTL_SECONDS = 24 * 60 * 60def idempotent(view_func):def wrapper(*args, **kwargs):key = request.headers.get("Idempotency-Key")if not key:return jsonify({"error": "Idempotency-Key header is required"}), 400body_hash = hashlib.sha256(request.get_data()).hexdigest()store_key = f"idem:{key}"reserved = r.set(store_key,json.dumps({"status": "in_progress", "body_hash": body_hash}),nx=True,ex=IDEMPOTENCY_TTL_SECONDS,)if not reserved:existing = json.loads(r.get(store_key))if existing.get("body_hash") != body_hash:return jsonify({"error": "Idempotency-Key reused with a different body"}), 422if existing.get("status") == "in_progress":return jsonify({"error": "Original request is still processing"}), 409return jsonify(existing["response_body"]), existing["status_code"]try:status_code, response_body = view_func(*args, **kwargs)except Exception:r.delete(store_key)raiser.set(store_key,json.dumps({"status": "completed","body_hash": body_hash,"status_code": status_code,"response_body": response_body,}),ex=IDEMPOTENCY_TTL_SECONDS,)return jsonify(response_body), status_codewrapper.__name__ = view_func.__name__return wrapper@app.route("/payments", methods=["POST"])@idempotentdef create_payment():data = request.get_json()payment = charge_card(data["card_token"], data["amount_cents"])return 201, {"id": payment.id, "status": payment.status}
A client calling this endpoint sends the key itself:
POST /payments HTTP/1.1Host: api.example.comIdempotency-Key: 6c1f1c9e-df7a-4b1a-9e2b-6a2b6a9b6e10Content-Type: application/json{ "card_token": "tok_abc123", "amount_cents": 4999 }
🧠 Check your understanding
0/3 · 0/3 answered1. Which pair of HTTP methods are idempotent but NOT safe?
2. A client's POST /payments request times out but the charge actually succeeded on the server. The client retries with the SAME Idempotency-Key. What should the server do?
3. Why must the server hash and store the request body alongside the idempotency key?