Medium Laravel

Idempotency Keys: Preventing Duplicate Payments in Distributed Systems

Why idempotency keys matter in payment APIs, how to implement them safely with a database-backed store, and common pitfalls like race conditions and key collisions.

18 Aug, 2026 4 min 11 Views 3 Code blocks
Diagram
sequenceDiagram participant C as Client participant S as Server participant D as DB C->>S: POST /charge (key=abc123) S->>D: BEGIN, SELECT ... FOR UPDATE (key=abc123) D-->>S: not found S->>D: INSERT key=abc123 status=processing S->>S: process charge S->>D: UPDATE key=abc123 status=completed, store response S->>D: COMMIT S-->>C: 200 OK Note over C,S: network drop, client never saw the 200 C->>S: POST /charge (key=abc123) [retry] S->>D: SELECT ... FOR UPDATE (key=abc123) D-->>S: found, status=completed S-->>C: 200 OK (stored response, no new charge)

Idempotency keys are the difference between a retried payment request and an accidental double-charge. In any distributed system where a client might retry a request after a timeout — mobile app on flaky wifi, a load balancer failing over, a queue redelivering a message — the server needs a way to recognize "I've already done this" and return the original result instead of repeating the side effect.

The Problem

Client -> POST /charge {amount: 5000} -> Server processes charge, DB write succeeds
Client -> (times out waiting for response, retries)
Client -> POST /charge {amount: 5000} -> Server processes AGAIN -> customer charged twice

The client never knows if the first request failed before or after the side effect happened. Without idempotency, a naive retry strategy silently causes double charges, duplicate emails, duplicate orders.

The Fix: Idempotency Keys

The client generates a unique key (usually a UUID) per logical operation and sends it in a header:

POST /api/v1/charges
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
{ "amount": 5000, "currency": "USD" }

The server stores the key alongside the result of the first successful execution. On retry with the same key, it returns the stored result without re-executing the side effect.

Implementation with a Database-Backed Store

function handleCharge(Request $request) {
    $key = $request->header('Idempotency-Key');
    if (!$key) {
        abort(400, 'Idempotency-Key header required');
    }

    return DB::transaction(function () use ($key, $request) {
        $existing = IdempotencyKey::where('key', $key)->lockForUpdate()->first();

        if ($existing) {
            if ($existing->status === 'processing') {
                abort(409, 'Request with this key is already being processed');
            }
            return response($existing->response_body, $existing->response_status);
        }

        IdempotencyKey::create([
            'key' => $key,
            'status' => 'processing',
            'request_hash' => hash('sha256', $request->getContent()),
        ]);

        $result = processCharge($request->all());

        IdempotencyKey::where('key', $key)->update([
            'status' => 'completed',
            'response_status' => 200,
            'response_body' => json_encode($result),
        ]);

        return response()->json($result);
    });
}

The row-level lock (lockForUpdate) inside the transaction is what prevents two concurrent requests with the same key from both slipping past the "does it exist" check — this is the race condition most naive implementations miss.

Sequence of a Safe Retry

sequenceDiagram participant C as Client participant S as Server participant D as DB C->>S: POST /charge (key=abc123) S->>D: BEGIN, SELECT ... FOR UPDATE (key=abc123) D-->>S: not found S->>D: INSERT key=abc123 status=processing S->>S: process charge S->>D: UPDATE key=abc123 status=completed, store response S->>D: COMMIT S-->>C: 200 OK Note over C,S: network drop, client never saw the 200 C->>S: POST /charge (key=abc123) [retry] S->>D: SELECT ... FOR UPDATE (key=abc123) D-->>S: found, status=completed S-->>C: 200 OK (stored response, no new charge)

Common Pitfalls

Not hashing the request body. If a client reuses a key with a different payload (different amount, say), returning the cached response silently applies the wrong charge to the wrong intent. Store a hash of the request body with the key and reject mismatches with a 422.

No expiration. Idempotency keys should expire (24-48 hours is typical for payment APIs). Keeping them forever bloats the table and risks legitimate reuse of a UUID being blocked incorrectly, however unlikely.

Treating "processing" as "not found." If a second request arrives while the first is still mid-flight (not a retry after failure, but genuine concurrency), returning 409 (Conflict) or making the client poll is safer than letting both proceed.

Scoping keys per-endpoint, not globally. The same key sent to two different endpoints should not collide. Compose the lookup on (key, endpoint) or (key, account_id), not just key alone.

Only applying this to POST. PUT and PATCH can double-apply too if not naturally idempotent (e.g., "increment balance by 100" instead of "set balance to 100"). Prefer absolute updates over relative ones where money is involved, and use idempotency keys for both.

When You Don't Need This

Truly idempotent operations by construction (a PUT that sets absolute state, a GET, a DELETE by ID) don't need a key — retrying them is already safe. Idempotency keys matter specifically when an operation has a side effect that isn't naturally safe to repeat: charging money, sending an email, decrementing inventory, creating a resource with server-generated side effects.

Rule of thumb: any endpoint that moves money, sends a notification, or creates a resource that shouldn't be duplicated needs an idempotency key. Everything else, question whether you're overengineering.

More from Laravel