API Idempotency Keys & Versioning

API Idempotency Keys & Versioning

A client sends POST /payments to charge $100. The response times out. The client retries. Your API charges $200. This is the fundamental problem that idempotency keys solve: making retries safe. API versioning solves a different but equally important problem: evolving your API without breaking existing clients.

Idempotency Keys

An idempotency key is a client-generated unique identifier (typically a UUID) sent with a request. The server uses it to detect duplicates: if a request with the same key has already been processed, the server returns the cached response without re-executing the operation.

sequenceDiagram
    participant C as Client
    participant S as Server
    participant DB as Database
    participant Cache as Idempotency Store

    C->>S: POST /payments {amount: 100}
Idempotency-Key: abc-123 S->>Cache: Lookup abc-123 Cache-->>S: Not found S->>DB: INSERT payment (amount=100) S->>Cache: Store abc-123 → {status: 200, body: {id: 42}} S->>C: 200 OK {id: 42} Note over C,S: Network timeout — client retries C->>S: POST /payments {amount: 100}
Idempotency-Key: abc-123 S->>Cache: Lookup abc-123 Cache-->>S: Found → {status: 200, body: {id: 42}} S->>C: 200 OK {id: 42} (cached — no duplicate charge)

Server-Side Implementation

def handle_payment(request):
    idempotency_key = request.headers.get("Idempotency-Key")
    if not idempotency_key:
        return error(400, "Idempotency-Key header required")

    # Check for existing result
    cached = redis.get(f"idem:{idempotency_key}")
    if cached:
        return json.loads(cached)

    # Lock to prevent concurrent duplicates
    lock_key = f"idem_lock:{idempotency_key}"
    if not redis.set(lock_key, "1", nx=True, ex=30):
        return error(409, "Request in progress — retry later")

    try:
        # Execute business logic
        result = process_payment(request.body)

        # Cache the result with TTL
        redis.setex(
            f"idem:{idempotency_key}",
            86400,  # 24 hour TTL
            json.dumps({"status": 200, "body": result})
        )
        return success(result)
    except Exception as e:
        redis.delete(lock_key)  # allow retry on failure
        raise

Storage for Idempotency Keys

StorageDurabilityLatencyTTL HandlingBest For
RedisVolatile (survives restart with AOF)Sub-millisecondBuilt-in TTL per keyMost API idempotency (payments, orders)
Database rowDurable (ACID)~1msManual cleanup jobFinancial transactions where durability is critical
In-memoryNone (lost on restart)NanosecondsProcess lifetimeSingle-instance services only

Stripe’s approach: stores idempotency keys in their database within the same transaction as the payment. The payment insert and idempotency record are atomic — no window for inconsistency.

Key Design Decisions

Who generates the key? The client — always. The server cannot generate a meaningful idempotency key because it doesn’t know which client retries map to which original request.

Key scope: one key per logical operation. If the client retries with the same key but a different request body, the server should return an error (not silently use the cached result or re-execute with new parameters).

TTL: 24 hours is typical. Too short and delayed retries fail. Too long and storage grows indefinitely.

⚠️

The key must be tied to a single request body. If the client reuses the same idempotency key with a different payload (e.g., same key but $200 instead of $100), the server should return 422 Unprocessable Entity — not the cached $100 result or a new $200 charge. Stripe enforces this by hashing the request body and comparing it on duplicate key access.

API Versioning

APIs evolve. Fields get added, renamed, or removed. Response formats change. Without versioning, any change risks breaking existing clients — and you can’t force all clients to upgrade simultaneously.

Versioning Strategies

StrategyExampleProsCons
URL pathGET /v1/ordersExplicit, simple routing, cacheableURL clutter; every resource path includes version
Custom headerAPI-Version: 2Clean URLsHeader can be forgotten; less discoverable
Content negotiationAccept: application/vnd.api.v2+jsonRESTful purist approachComplex; poor tooling support
Query parameterGET /orders?version=2Simple to addPollutes the query string; breaks caching

URL path versioning dominates in practice. Stripe (/v1/), GitHub (/v3/), Twilio (/2010-04-01/) all use URL-based versioning because it’s explicit and hard to miss.

Breaking vs Non-Breaking Changes

ChangeBreaking?Why
Add a new field to the responseNoExisting clients ignore unknown fields
Add a new optional request parameterNoExisting clients don’t send it; server uses default
Remove a response fieldYesClients parsing that field will break
Rename a fieldYesSame as remove old + add new
Change a field’s type (string → int)YesClients deserializing the old type will crash
Change error response formatYesClient error handling breaks
Add a required request parameterYesExisting clients don’t send it
Tighten validation (reject previously valid input)YesRequests that used to work now fail

Rule of thumb: you can always add optional things. You can never remove, rename, or change the type of existing things without a version bump.

Sunset and Deprecation

When you release v2, v1 doesn’t disappear overnight. A deprecation lifecycle:

v1 released: 2024-01-01
v2 released: 2025-01-01
v1 deprecated: 2025-01-01 (announced, still works)
v1 sunset: 2025-07-01 (returns 410 Gone)

Timeline:
|------ v1 active ------|-- v1 deprecated --|-- v1 gone --|
                         |------------- v2 active ----------|

Response headers for deprecation:

HTTP/1.1 200 OK
Sunset: Sat, 01 Jul 2025 00:00:00 GMT
Deprecation: true
Link: <https://api.example.com/docs/migration-v2>; rel="deprecation"

Monitoring: track the percentage of traffic still hitting deprecated versions. If 30% of traffic is on v1 three months before sunset, send targeted migration reminders or extend the grace period.

Date-Based Versioning

An alternative to incremental version numbers: use the API release date as the version identifier.

Stripe: API-Version: 2024-12-18
Twilio: /2010-04-01/Accounts/...

Advantage: finer granularity — each API release date is a version. Clients pin to the date they integrated, and new changes are introduced under newer dates. The server maintains backward compatibility for all pinned dates within the support window.

Comparison: Idempotency vs Versioning

ConcernIdempotency KeysAPI Versioning
Problem solvedSafe retries — prevent duplicate side effectsAPI evolution — don’t break existing clients
Who’s responsibleClient generates key, server enforcesServer maintains multiple versions
Typical HTTP methodsPOST, PUT, PATCH (non-idempotent by default)All methods — versioning applies to the contract
Storage costPer-key with TTLPer-version code paths
ℹ️

Interview tip: When designing an API, say: “All mutating endpoints require an Idempotency-Key header — the server stores the key and result in Redis with a 24-hour TTL. Duplicates return the cached response. For versioning, I’d use URL path versioning (/v1/) with a 6-month deprecation window. Non-breaking changes (new optional fields) go into the current version; breaking changes get a new version. The Sunset header tells clients when a version will be removed.” This shows you’ve thought about client safety (idempotency) and API lifecycle (versioning) as separate concerns.