Designing Idempotent API Endpoints for Payments: Stripe's Approach

Arpit Bhayani

Arpit Bhayani

Jan 29, 2023 • 7 min read

Play

In distributed payment architectures, network failures are not edge cases—they are an operational certainty. Consider an endpoint like POST /payments/transfer that deducts 10,000fromAccountAandcreditsAccountB.Ifthenetworkdropsduringthecall,anautomatedclientretrycouldeasilydeductanadditional10,000 from Account A and credits Account B. If the network drops during the call, an automated client retry could easily deduct an additional 10,000, resulting in accidental double charges and corrupted financial ledger balances.

To build robust financial systems, APIs must provide idempotent semantics: ensuring that no matter how many times a client repeats an identical request, the backend executes the side effect exactly once.


The Anatomy of Network Failures

When a client issues an HTTP request across a network, failures can occur at three distinct phases of the request lifecycle. Because HTTP timeouts abstract away the underlying transport state, the client cannot easily distinguish between these failure points:

sequenceDiagram
    autonumber
    participant Client
    participant Network
    participant Server

    Note over Client,Server: Failure Mode 1: Pre-execution Drop
    Client->>Network: POST /payments/transfer
    Network--xServer: Connection dropped before arrival
    Note over Client: Safe to retry (Zero execution)

    Note over Client,Server: Failure Mode 2: Mid-execution Crash
    Client->>Server: POST /payments/transfer
    Server->>Server: Deduct balance (Incomplete)
    Server--xClient: TCP reset / Connection dropped
    Note over Client: Indeterminate state (Partial execution)

    Note over Client,Server: Failure Mode 3: Post-execution Drop
    Client->>Server: POST /payments/transfer
    Server->>Server: Transaction committed successfully
    Server--xNetwork: 200 OK dropped on return path
    Note over Client: Retry causes double charge (Full execution)

1. Pre-execution Drop (Before Reaching the Server)

The connection terminates before packets arrive at the backend. The server never executes the request. In isolation, retrying this request is completely safe.

2. Mid-execution Failure (During Server Processing)

The server receives the payload and initiates balance deduction, but the connection drops before the transaction finalizes. The client experiences an indeterminate state: the money may or may not have left the account.

3. Post-execution Drop (Response Lost on Return Path)

The server successfully executes the transaction, writes to the ledger, and prepares an HTTP 200 OK. However, the network fails while streaming the response back to the client. The client times out, observes an error, and assumes the request failed—even though the state mutation is permanent.

The Dilemma of Blind Retries

If the client client-side policy defaults to automatic retries across network drops: Total Deducted=N×Amount\text{Total Deducted} = N \times \text{Amount} where NN is the number of retry attempts. Blind retries without idempotency lead directly to duplicated mutations and financial loss.


Why Implicit Request Fingerprinting Fails

Engineers often attempt to infer duplicate requests implicitly without modifying the API contract:

  1. URL-only matching: Storing recently requested URLs fails immediately because subsequent distinct payments share identical endpoints (e.g., /payments/transfer).
  2. Payload hashing (Hash(URL + Body + Headers)): Computing a SHA-256 hash over the request body and HTTP headers is brittle:
    • Insignificant formatting changes (e.g., whitespace, key reordering in JSON) produce different hashes for the same logical operation.
    • If a user legitimately initiates two separate transfers of $100 to the same person within a minute, the hashes will collide, incorrectly suppressing the second valid transfer.
    • Dynamic headers (timestamps, tracing IDs like X-Request-Id, authentication tokens) change with every retry, defeating hash comparisons.

Implicit detection conflates request content with user intent. To reliably guarantee idempotency, intent must be made explicit.


The Idempotency Key Pattern (Stripe’s Approach)

Instead of guessing intent on the server, the client explicitly generates and attaches a unique Idempotency Key to the request (commonly passed in the HTTP header Idempotency-Key: <UUID>).

flowchart TD
    A[Client Request with Idempotency Key] --> B{Key exists in Cache/DB?}
    B -- No --> C[Acquire Lock & Create Key Record: IN_PROGRESS]
    C --> D[Execute Business Logic / DB Transaction]
    D --> E[Save Response Payload & Status: COMPLETED]
    E --> F[Return HTTP Response]
    B -- Yes --> G{Status?}
    G -- IN_PROGRESS --> H[Return 409 Conflict or Wait]
    G -- COMPLETED --> I[Return Cached Response]

The End-to-End Protocol Flow

  1. Intent Generation: Before initiating an operation, the client generates a cryptographically secure random token (e.g., UUIDv4) representing that specific financial action.
  2. Dispatch with Header: The client passes the token in the header:
    POST /v1/charges HTTP/1.1
    Host: api.stripe.com
    Idempotency-Key: 9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d
    Content-Type: application/json
    
    {
      "amount": 10000,
      "currency": "usd",
      "destination": "acc_12345"
    }
  3. Server-side Check:
    • The server queries an auxiliary low-latency store (e.g., Redis or an auxiliary relational table) for the tuple (user_id, idempotency_key).
    • First Time Seen: The server inserts the key with an IN_PROGRESS lock state, runs the underlying database transaction, saves the resulting HTTP status code and response body, marks the key as COMPLETED, and returns the response.
    • Subsequent Requests (Retries): The server finds the key in the auxiliary store with status COMPLETED. It skips execution entirely and replays the cached response back to the client.
    • Concurrent Retries: If a retry arrives while the initial operation is still IN_PROGRESS, the server rejects it with a conflict (e.g., HTTP 409 Conflict or concurrent-lock wait) to prevent race conditions.

System Architecture

Implementing idempotency requires decoupling API gateway orchestration from the core transaction database.

graph LR
    Client[Client App] --> Gateway[API Server / Gateway]
    Gateway -->|1. Check / Acquire Key| IdemStore[(Auxiliary Key-Value Store
Redis / RDBMS with TTL)]
    Gateway -->|2. Execute Core Transaction| CoreDB[(Payments Database)]
    Gateway -->|3. Cache Result| IdemStore

Component Responsibilities

  1. API Server / Middleware: Intercepts incoming requests prior to hitting controller logic. It parses the Idempotency-Key header, manages lock acquisition, and coordinates cache hydration.
  2. Auxiliary Store (Redis / Key-Value Store):
    • Serves as the primary gatekeeper for rapid lookups before hitting the database.
    • Key layout: idempotency:<user_id>:<idempotency_key>.
    • Employs a Time-To-Live (TTL) (typically 24 to 72 hours). After the TTL expires, the key is purged to keep storage requirements bounded.
  3. Core Payments Database: Remains isolated from repeated retry pressure. It only executes transactions that have cleared the idempotency filter.

Implementation Nuances & Edge Cases

1. Payload Mismatch on Duplicate Keys

What happens if a client uses the same idempotency key for two completely different requests?

# Request 1
POST /charges (Idempotency-Key: key_abc) -> { "amount": 1000 }

# Request 2 (Erroneous or Malicious reuse)
POST /charges (Idempotency-Key: key_abc) -> { "amount": 5000 }

Best Practice: Store a hash of the original request payload alongside the idempotency record. If the key exists but Hash(incoming_payload) != stored_hash, fail fast by returning 400 Bad Request or 422 Unprocessable Entity indicating an idempotency key reuse mismatch.

2. Failure Recovery & Error Caching

If the payment gateway returns an unrecoverable failure (e.g., 402 Card Declined), that response should also be cached under the idempotency key. Subsequent retries will receive the exact same declined response rather than triggering another attempt against the card processor.

Conversely, if an internal infrastructure failure occurs (e.g., database connection timeout yielding a 500 Internal Server Error), the idempotency lock should be released or evicted so that subsequent retries are allowed to re-execute the business logic.

3. Atomicity of Key Acquisition

Checking whether a key exists and writing its pending state must be atomic to prevent race conditions from concurrent duplicate requests. In Redis, this is achieved using atomic primitives:

SET idempotency:<user_id>:<key> "IN_PROGRESS" NX EX 120

The NX option ensures the key is written only if it does not already exist, and EX 120 prevents deadlocks by expiring stalled operations after two minutes.


Summary of Key Takeaways

ConceptNaive ImplementationIdempotency Key Architecture
MechanismBlind retries or implicit payload hashingExplicit client-supplied unique tokens
Handling Network TimeoutMay result in duplicate charges (N×N \times cost)Exactly-once business execution guaranteed
Concurrent RetriesRace conditions and ledger inconsistenciesHandled via atomic locks (IN_PROGRESS)
Response ReplayRecomputes logic or drops requestsReturns identical, cached HTTP responses
Storage OverheadUnbounded database bloatControlled with bounded TTLs (e.g., 24-72 hours)
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses