system design

Idempotency in APIs: Preventing Duplicate Operations

Learn what idempotency means in API design and why it matters for payments, retries, and distributed systems. With practical implementation patterns.

By Akash Sharma·6 min read
#idempotency
#api design
#system design
#backend
#distributed systems
#payments
#reliability

Your user clicks "Pay Now." The request goes out. Network hiccup — the response never arrives. Did the payment go through?

They click again. Now did they pay twice?

This is the idempotency problem.

What Is Idempotency?

An operation is idempotent if doing it multiple times has the same result as doing it once.

plaintext
Idempotent:
  Set price = $100         → Run 3 times → price = $100 ✓
  DELETE /orders/123       → Run 3 times → order deleted once ✓
  GET /users/123           → Run 100 times → same user data ✓
 
Not idempotent:
  Increment counter by 1   → Run 3 times → counter +3, not +1 ✗
  POST /orders (new order) → Run 3 times → 3 orders created ✗
  Charge card $100         → Run 3 times → $300 charged ✗

HTTP methods have idempotency expectations built in:

  • GET, HEAD, OPTIONS — safe (read-only, definitely idempotent)
  • PUT, DELETE — idempotent by design
  • POST — not idempotent by default

Why It Matters: Retries Are Inevitable

Networks fail. Servers crash. Clients timeout. In any distributed system, you need retries. But retries on non-idempotent operations cause duplicates.

The failure scenarios:

  1. Request sent, server never received it → retry is safe
  2. Server processed it, response lost in transit → retry causes duplicate
  3. Server is slow, client times out and retries → server may process twice

You can't tell which scenario happened from the client's side. Idempotency lets you safely retry without checking first.

Idempotency Keys

The standard pattern for making non-idempotent operations safe: the client sends a unique key with the request. The server uses it to detect duplicates.

plaintext
Client → POST /payments
         Idempotency-Key: uuid-abc-123
         Body: { amount: 100, card: ... }
 
Server:
  1. Check if uuid-abc-123 was seen before
  2. If yes → return stored response (don't process again)
  3. If no → process payment, store response against uuid-abc-123

This is what Stripe does. Every POST to Stripe supports Idempotency-Key:

python
import stripe
 
stripe.api_key = "sk_..."
 
# First call — processes payment
result = stripe.PaymentIntent.create(
    amount=1000,
    currency="usd",
    idempotency_key="order_123_payment_attempt_1"
)
 
# Retry (network failed, client retries) — returns same result, no duplicate charge
result = stripe.PaymentIntent.create(
    amount=1000,
    currency="usd",
    idempotency_key="order_123_payment_attempt_1"  # Same key
)

Implementing Idempotency

Here's a practical pattern using Redis to store processed requests:

python
import redis
import json
import uuid
from fastapi import FastAPI, Header, HTTPException, Request
from datetime import timedelta
 
app = FastAPI()
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
 
IDEMPOTENCY_TTL = timedelta(days=1)  # Store keys for 24 hours
 
def idempotent(ttl: timedelta = IDEMPOTENCY_TTL):
    def decorator(func):
        async def wrapper(*args, idempotency_key: str = Header(None), **kwargs):
            if not idempotency_key:
                raise HTTPException(status_code=400, detail="Idempotency-Key header required")
            
            cache_key = f"idem:{idempotency_key}"
            
            # Check if we've seen this key before
            cached = r.get(cache_key)
            if cached:
                # Return the stored response — don't process again
                return json.loads(cached)
            
            # Process the request
            result = await func(*args, **kwargs)
            
            # Store the result
            r.setex(cache_key, int(ttl.total_seconds()), json.dumps(result))
            
            return result
        return wrapper
    return decorator
 
@app.post("/payments")
@idempotent()
async def create_payment(request: Request, idempotency_key: str = Header(None)):
    body = await request.json()
    
    # Process payment (only runs once per idempotency key)
    payment_id = process_payment(body["amount"], body["card_token"])
    
    return {"payment_id": payment_id, "status": "success"}
python
# Database-backed approach (more durable than Redis alone)
from sqlalchemy import Column, String, JSON, DateTime
from datetime import datetime, timedelta
 
class IdempotencyRecord(Base):
    __tablename__ = "idempotency_records"
    
    key = Column(String, primary_key=True)
    response = Column(JSON)
    created_at = Column(DateTime, default=datetime.utcnow)
    expires_at = Column(DateTime)
 
def get_or_create_idempotent_response(key: str, handler):
    record = db.query(IdempotencyRecord).filter_by(key=key).first()
    
    if record and record.expires_at > datetime.utcnow():
        return record.response  # Return cached response
    
    # Not seen before — process and store
    result = handler()
    
    record = IdempotencyRecord(
        key=key,
        response=result,
        expires_at=datetime.utcnow() + timedelta(days=1)
    )
    db.add(record)
    db.commit()
    
    return result

The Concurrent Request Problem

What if the same key arrives twice at the same time? You need a lock:

python
import redis.lock
 
def process_idempotent(key: str, handler):
    cache_key = f"idem:{key}"
    lock_key = f"idem_lock:{key}"
    
    # Try to get cached response first (fast path)
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)
    
    # Acquire lock to prevent concurrent processing
    with r.lock(lock_key, timeout=30):
        # Check again inside lock (another request may have processed while we waited)
        cached = r.get(cache_key)
        if cached:
            return json.loads(cached)
        
        # Process once
        result = handler()
        r.setex(cache_key, 86400, json.dumps(result))
        return result

Without the lock, two concurrent requests with the same key could both check the cache (both miss), both process, and both write — causing a duplicate.

What to Include in Idempotency Keys

The client generates keys. They must be:

  • Unique per operation attempt: Two different payment attempts = two different keys
  • Stable across retries: Retry of the same attempt = same key
  • Unpredictable: Use UUID v4 or a hash of the operation parameters
python
import uuid
 
# Simple: UUID per user action
key = str(uuid.uuid4())
 
# Or deterministic: hash of operation + user ID (dangerous if parameters change)
import hashlib
key = hashlib.sha256(f"{user_id}:{order_id}:{amount}:{timestamp_minute}".encode()).hexdigest()

Designing Idempotent APIs

Beyond the idempotency key pattern, design your endpoints to be naturally idempotent where possible:

Use PUT instead of POST for updates: PUT /users/123/email with {email: "new@example.com"} is idempotent. Running it 3 times sets the same email. POST /users/123/update-email is ambiguous.

Avoid operations like "add 10 points": Instead, set the total — "set points to 150." The outcome is the same regardless of retries.

Return the same response on retry: Don't return 201 Created on the first call and 200 OK on retry. Some clients check status codes. Keep it consistent, or always return 200 with the resource state.

Key Takeaways

  • Idempotency: running an operation multiple times = same result as running once
  • GET, PUT, DELETE are idempotent by spec; POST is not
  • Retries are unavoidable in distributed systems — idempotency makes them safe
  • Idempotency keys: client sends a unique ID; server detects and returns cached responses for duplicates
  • Store processed keys in Redis or database with a TTL (24 hours is common)
  • Use locks to prevent concurrent requests with the same key from both processing
  • Design endpoints with PUT and absolute values where possible to be naturally idempotent

Idempotency is what separates reliable APIs from ones that silently charge users twice.

Related reading: REST API Design Best Practices · Rate Limiting Your API · Message Queues Explained

Enjoyed this article?

Get weekly insights on backend architecture, system design, and Go programming.