The user double-clicks Pay. The mobile app times out at 30s and retries. Your server charged the card twice. Finance escalates. Idempotency is the property that makes “at least once” delivery on flaky networks safe: doing the same logical operation one time or ten times yields one business effect.
HTTP already treats GET, PUT, and DELETE as idempotent. POST /charges is not — each call may create a new charge. The fix used by Stripe and many payment APIs: the client sends an Idempotency-Key (UUID) with the first attempt. The server stores the key → outcome. Retries with the same key return the same result without re-charging.
Idempotency is not only payments. Order creation, seat booking, “send invite email,” and wallet transfers all need it whenever clients retry or users mash buttons.
Implementation checklist:
409 until the first finishes.Hash the request body with the key: if the same key arrives with a different body, return 422 — do not silently ignore.
FastAPI + SQLite idempotent charge endpoint with a uniqueness constraint.
import json
import hashlib
import sqlite3
from fastapi import FastAPI, Header, HTTPException
from pydantic import BaseModel, Field
app = FastAPI()
def db():
c = sqlite3.connect("payments.db")
c.row_factory = sqlite3.Row
c.execute("PRAGMA foreign_keys=ON")
return c
@app.on_event("startup")
def init():
with db() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- processing | completed
response_code INTEGER,
response_body TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS charges (
id INTEGER PRIMARY KEY,
amount_cents INTEGER NOT NULL,
user_id INTEGER NOT NULL
);
"""
)
class ChargeIn(BaseModel):
user_id: int
amount_cents: int = Field(gt=0)
def body_hash(body: ChargeIn) -> str:
raw = json.dumps(body.model_dump(), sort_keys=True)
return hashlib.sha256(raw.encode()).hexdigest()
@app.post("/charges")
def create_charge(
body: ChargeIn,
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
if not idempotency_key:
raise HTTPException(400, "Idempotency-Key required")
rh = body_hash(body)
with db() as conn:
existing = conn.execute(
"SELECT * FROM idempotency_keys WHERE key=?", (idempotency_key,)
).fetchone()
if existing:
if existing["request_hash"] != rh:
raise HTTPException(422, "Idempotency-Key reused with different body")
if existing["status"] != "completed":
raise HTTPException(409, "Request in progress; retry later")
return JSON_safe(existing["response_code"], existing["response_body"])
try:
conn.execute(
"INSERT INTO idempotency_keys (key, request_hash, status) "
"VALUES (?, ?, 'processing')",
(idempotency_key, rh),
)
conn.commit()
except sqlite3.IntegrityError:
# raced with another request
raise HTTPException(409, "Request in progress; retry later")
# Side effect once
cur = conn.execute(
"INSERT INTO charges (amount_cents, user_id) VALUES (?, ?)",
(body.amount_cents, body.user_id),
)
charge_id = cur.lastrowid
payload = {"charge_id": charge_id, "amount_cents": body.amount_cents}
conn.execute(
"UPDATE idempotency_keys SET status='completed', "
"response_code=201, response_body=? WHERE key=?",
(json.dumps(payload), idempotency_key),
)
conn.commit()
return payload
def JSON_safe(code: int, body: str):
from fastapi.responses import JSONResponse
return JSONResponse(status_code=code, content=json.loads(body))
Natural SQL uniqueness helps too: UNIQUE(user_id, client_order_id) so a duplicate insert fails closed even if the idempotency table is wrong.
SELECT FOR UPDATE.Idempotency keys (plus unique constraints) make retried POSTs safe by recording each logical operation once and replaying the stored result.