Idempotency Keys for Python Payment APIs
A user double-taps Pay. The network retries. Your API creates two orders for one charge.
POST is not idempotent. If you handle payments without an idempotency strategy, you will eventually ship a bug that finance notices before engineering does.
I've debugged checkout flows in production — post-payment 500s, guest checkout edge cases, gateway parity. Here are the patterns I use for idempotent payment endpoints in Python.
Companion on Medium: What Production Checkout Taught Me About Idempotency — reconciliation, webhooks, and state machines.
1. The problem in one request
POST /orders HTTP/1.1
Content-Type: application/json
{"amount": 9900, "currency": "AED", "payment_method_id": "pm_abc"}
The client sends this. The server charges the card and creates an order. The response never reaches the app. The client retries the same POST.
Without protection: two orders, or one order and one orphan charge.
2. Idempotency keys — the standard fix
The client generates a unique key once per user action and sends it on every retry:
POST /orders HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{"amount": 9900, "currency": "AED", "payment_method_id": "pm_abc"}
Server rules:
- First request with key
K→ process normally, store(K → response) - Duplicate request with key
K→ return stored response, do not re-charge
Stripe popularized this header. You can implement the same pattern on any checkout API.
3. Database schema
CREATE TABLE idempotency_keys (
key VARCHAR(64) PRIMARY KEY,
request_hash TEXT NOT NULL,
response_body JSONB NOT NULL,
status_code INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Optional: tie to business entity
CREATE UNIQUE INDEX orders_idempotency_key_idx ON orders (idempotency_key);
Store the key before calling the payment gateway when possible (with status processing) to win race conditions between concurrent retries.
4. FastAPI implementation
from fastapi import FastAPI, Header, HTTPException, Request
from sqlalchemy.orm import Session
import hashlib
import json
app = FastAPI()
def request_hash(body: dict) -> str:
return hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
@app.post("/orders")
async def create_order(
body: dict,
db: Session = Depends(get_db),
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
):
if not idempotency_key:
raise HTTPException(400, "Idempotency-Key header required")
existing = db.query(IdempotencyRecord).filter_by(key=idempotency_key).first()
if existing:
if existing.request_hash != request_hash(body):
raise HTTPException(422, "Idempotency key reused with different body")
return JSONResponse(existing.response_body, status_code=existing.status_code)
# Reserve the key (status: processing) — prevents duplicate charges under concurrency
record = IdempotencyRecord(key=idempotency_key, request_hash=request_hash(body), status="processing")
db.add(record)
db.commit()
try:
charge = payment_gateway.charge(body)
order = Order(amount=body["amount"], charge_id=charge.id, idempotency_key=idempotency_key)
db.add(order)
response = {"order_id": order.id, "status": "paid"}
record.response_body = response
record.status_code = 201
record.status = "completed"
db.commit()
return JSONResponse(response, status_code=201)
except Exception:
db.rollback()
raise
Key details:
- Reject keys reused with a different body (422)
- Store response before returning to client
- Use a DB transaction around order + idempotency record
5. Flask — lighter version
from flask import Flask, request, jsonify
from functools import wraps
app = Flask(__name__)
# Production: Redis or Postgres, not in-memory
_idempotency_store = {}
@app.post("/orders")
def create_order():
key = request.headers.get("Idempotency-Key")
if not key:
return jsonify({"error": "Idempotency-Key required"}), 400
body = request.get_json()
if key in _idempotency_store:
stored = _idempotency_store[key]
if stored["body_hash"] != hash(body):
return jsonify({"error": "Key reused with different payload"}), 422
return jsonify(stored["response"]), stored["status"]
charge = charge_customer(body)
order_id = save_order(body, charge.id)
response = {"order_id": order_id, "status": "paid"}
_idempotency_store[key] = {"body_hash": hash(body), "response": response, "status": 201}
return jsonify(response), 201
Use Redis with TTL (24–72 hours) or Postgres in production — not a module-level dict.
6. Gateway-level idempotency
Most gateways accept their own idempotency key:
# Stripe example
stripe.PaymentIntent.create(
amount=9900,
currency="aed",
idempotency_key=idempotency_key, # same key as your API
)
Defense in depth: idempotency at your API layer and at the gateway. Your order service should not depend on the gateway alone — webhooks and retries can still duplicate rows.
7. Status reconciliation
Sometimes the charge succeeds but your API crashes before saving the order. Run a reconciliation job:
def reconcile_orphan_charges():
pending = db.query(Order).filter_by(status="payment_pending").all()
for order in pending:
gateway_status = payment_gateway.get_charge(order.charge_id)
if gateway_status == "succeeded":
order.status = "paid"
elif gateway_status == "failed":
order.status = "failed"
db.commit()
Webhooks (payment_intent.succeeded) are the other half — never rely only on the synchronous response.
8. Guest checkout edge case
Guest orders often have user_id: null in JSON. Some serializers treat missing field and null differently. Downstream services that expect "no user" as absent field break when they receive explicit null.
Normalize at the API boundary:
def normalize_guest(body: dict) -> dict:
if body.get("user_id") is None:
body.pop("user_id", None) # or store as NULL consistently — pick one
return body
Parity bugs here cause post-payment 500s that look like payment failures but are mapping issues.
Checklist
- [ ]
Idempotency-Keyrequired on POST/ordersand POST/payments - [ ] Store key → response in DB or Redis with TTL
- [ ] Reject same key + different body (422)
- [ ] Pass idempotency key through to payment gateway
- [ ] Webhook handler updates order status
- [ ] Reconciliation job for stuck
payment_pendingorders - [ ] Unique constraint on
orders.idempotency_key
Further reading
Muhammad Umair Virk — Backend Engineer, UAE. Python · AWS · microservices · payments.