Community/Tech articles

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 400 caused by bad input should be replayed, because retrying won't change it. A 500 caused 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

  1. Generate the key once per user action, not once per HTTP attempt.
  2. Retry with backoff on timeouts, connection errors, 409 and 5xx.
  3. Do not retry on 4xx validation 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

Practical guides and independent tool overviews from the RecallRun team. Every post is written to be tested on your own machine.

Website

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

Write for RecallRun

Share a tech article or a tool you built. Every post is checked and reviewed before it goes live.

Start writing