Networks fail. Clients timeout. Users double-click. If your API creates duplicate orders or charges cards twice when this happens, you have a problem. Idempotency is the solution—making operations safe to retry without side effects.

What Is Idempotency?

An operation is idempotent if performing it multiple times has the same effect as performing it once.

#GPD#PPEUEOOITTLNSSdEOTTe//TTmuuEpssiuoee/drstrruedeesssmern//eprst11ros/:22st133/e2S1n3a{2t/m.3:ce.h.Dar}irefgsfeuelrt###e##neAAUtCCvllsrhewwerearaarearyyystgss1ueet2lssirs3tmeeatettiehussaNercEnughWcssoaentorureirdsmde1(eear2arg3la1rei2tean3oacdhetyahtcigihsomnetseitma=etestillgone)

The Problem

Client sends request → Server processes it → Response lost in transit:

Client----P[P2OtO0SiS1TmTeCorouoertrad/dteeeerrrdsrs-o-#-r-1-]-0---0---2------------S>->-erv((ec(crrrreeeatatrteyes)soorrddeerr##11000012)!!)

The client retried because it didn’t know the first request succeeded. Now there are two orders.

Idempotency Keys

The fix: clients provide a unique key. Server uses it to deduplicate:

PIC{Odo"SeniTmttpeeonmottsre-"dnT:ecyryp[s-e.K:.e.ya]:p,pol"ritdco_atrtaeilqo"_n:a/bj9cs91o.2n939}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from functools import wraps
import hashlib
import json

def idempotent(ttl_seconds=86400):
    def decorator(f):
        @wraps(f)
        def wrapper(request, *args, **kwargs):
            key = request.headers.get("Idempotency-Key")
            if not key:
                return f(request, *args, **kwargs)  # No key = no protection
            
            cache_key = f"idempotency:{key}"
            
            # Check if we've seen this request before
            cached = redis.get(cache_key)
            if cached:
                return json.loads(cached)
            
            # Process the request
            response = f(request, *args, **kwargs)
            
            # Cache the response
            redis.setex(cache_key, ttl_seconds, json.dumps(response))
            
            return response
        return wrapper
    return decorator

@app.post("/orders")
@idempotent(ttl_seconds=86400)
def create_order(request):
    order = Order.create(request.json)
    return {"id": order.id, "status": "created"}

Now retries return the cached response instead of creating duplicates.

Key Generation

Clients generate keys. Make it easy to get right:

1
2
3
4
5
6
7
8
// Option 1: UUID
const idempotencyKey = crypto.randomUUID();

// Option 2: Hash of meaningful data
const idempotencyKey = hash(userId + cartId + timestamp);

// Option 3: Client transaction ID
const idempotencyKey = `order_${userId}_${Date.now()}`;

Important: The same key with different request bodies should error, not silently succeed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
def idempotent_wrapper(request):
    key = request.headers.get("Idempotency-Key")
    cached = redis.hgetall(f"idempotency:{key}")
    
    if cached:
        # Verify request body matches
        request_hash = hash_body(request.json)
        if cached["request_hash"] != request_hash:
            raise HTTPException(
                422, 
                "Idempotency key reused with different request body"
            )
        return json.loads(cached["response"])
    
    # ... process and cache

Database-Level Idempotency

For critical operations, enforce idempotency at the database level:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- Unique constraint prevents duplicates
CREATE TABLE orders (
    id SERIAL PRIMARY KEY,
    idempotency_key VARCHAR(255) UNIQUE,
    user_id INTEGER,
    total DECIMAL(10,2),
    created_at TIMESTAMP DEFAULT NOW()
);

-- Insert with conflict handling
INSERT INTO orders (idempotency_key, user_id, total)
VALUES ('ord_req_abc123', 42, 99.99)
ON CONFLICT (idempotency_key) 
DO UPDATE SET id = orders.id  -- No-op, just return existing
RETURNING *;

This survives server restarts and works across distributed systems.

Stripe’s Pattern

Stripe’s idempotency implementation is the gold standard:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import stripe

# First request
charge = stripe.Charge.create(
    amount=2000,
    currency="usd",
    source="tok_visa",
    idempotency_key="charge_abc123"
)

# Retry (same key) - returns same charge, doesn't charge again
charge = stripe.Charge.create(
    amount=2000,
    currency="usd", 
    source="tok_visa",
    idempotency_key="charge_abc123"
)

Key behaviors:

  • Keys expire after 24 hours
  • Different request body with same key → error
  • Keys are scoped to your API key
  • Responses are cached atomically with the operation

Naturally Idempotent Operations

Some operations are idempotent by design:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# PUT replaces entire resource - inherently idempotent
@app.put("/users/{user_id}")
def update_user(user_id: int, data: UserUpdate):
    user = User.get(user_id)
    user.name = data.name
    user.email = data.email
    user.save()
    return user

# DELETE is idempotent - deleting twice = still deleted
@app.delete("/users/{user_id}")
def delete_user(user_id: int):
    user = User.get_or_none(user_id)
    if user:
        user.delete()
    return {"status": "deleted"}  # Same response whether existed or not

Making POST Idempotent

POST creates new resources—not naturally idempotent. Options:

Option 1: Idempotency keys (shown above)

Option 2: Client-generated IDs

1
2
3
4
5
6
7
8
@app.post("/orders")
def create_order(order_id: str, data: OrderCreate):
    # Client provides the ID
    existing = Order.get_or_none(order_id)
    if existing:
        return existing  # Already exists, return it
    
    return Order.create(id=order_id, **data.dict())

Option 3: Natural keys

1
2
3
4
5
6
7
8
@app.post("/subscriptions")
def subscribe(user_id: int, plan_id: str):
    # Only one subscription per user per plan
    existing = Subscription.get_or_none(user_id=user_id, plan_id=plan_id)
    if existing:
        return existing
    
    return Subscription.create(user_id=user_id, plan_id=plan_id)

Response Codes

Be consistent about what retries return:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@app.post("/orders")
@idempotent()
def create_order(request):
    key = request.headers.get("Idempotency-Key")
    
    existing = Order.get_by_idempotency_key(key)
    if existing:
        # Return 200, not 201 - resource already existed
        return JSONResponse(existing.to_dict(), status_code=200)
    
    order = Order.create(...)
    # Return 201 - resource newly created
    return JSONResponse(order.to_dict(), status_code=201)

Clients can distinguish “created” (201) from “already existed” (200) if they need to.

Testing Idempotency

Always test retry behavior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def test_create_order_idempotent():
    key = "test_key_123"
    data = {"items": [{"sku": "ABC", "qty": 1}]}
    
    # First request
    response1 = client.post(
        "/orders",
        json=data,
        headers={"Idempotency-Key": key}
    )
    assert response1.status_code == 201
    order_id = response1.json()["id"]
    
    # Retry with same key
    response2 = client.post(
        "/orders",
        json=data,
        headers={"Idempotency-Key": key}
    )
    assert response2.status_code == 200  # Not 201
    assert response2.json()["id"] == order_id  # Same order
    
    # Verify only one order exists
    orders = Order.filter(idempotency_key=key)
    assert len(orders) == 1

Quick Reference

HTTP MethodNaturally Idempotent?Strategy
GET✅ YesNone needed
PUT✅ YesNone needed
DELETE✅ YesNone needed
POST❌ NoIdempotency keys
PATCH❌ Usually noIdempotency keys or versioning

The rule: Any operation that creates resources or has side effects needs an idempotency strategy. Networks are unreliable. Retries will happen. Be ready.