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:

  1. First request with key K → process normally, store (K → response)
  2. 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-Key required on POST /orders and 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_pending orders
  • [ ] Unique constraint on orders.idempotency_key

Further reading

Muhammad Umair Virk — Backend Engineer, UAE. Python · AWS · microservices · payments.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论