A customer taps Pay. The spinner turns for eight seconds. Nothing happens, so they tap again. Two minutes later their bank app shows two charges of LKR 5,000, and your support inbox gets an angry email.

Nobody wrote a bug that says “charge twice”. The duplicate came from something ordinary: a slow network, a retry, an impatient thumb. Any system that moves money over a network will see the same request more than once. The question is whether it charges more than once.

The fix is idempotency. This note explains why duplicates happen, walks through real failure scenarios, and then builds an idempotency layer step by step in Laravel.

If you are new to how online payments flow, read Payment Gateways Explained first.


What “idempotent” means

An operation is idempotent if doing it once or doing it many times gives the same result.

  • Pressing a lift’s call button five times still calls the lift once. That’s idempotent.
  • SET balance = balance - 100 takes more money every time it runs. Not idempotent.

HTTP GET, PUT and DELETE are supposed to be idempotent. POST is not, and POST /payments is where the money moves. So we have to make it idempotent ourselves.

✗ Without idempotency POST /pay no key POST /pay retry POST /pay retry Charge 5,000 Charge 5,000 Charge 5,000 3 requests → 3 charges The customer pays LKR 15,000. ✓ With an idempotency key POST /pay key: 7c9e… POST /pay key: 7c9e… POST /pay key: 7c9e… Charge 5,000 Replay saved result Replay saved result 3 requests → 1 charge Retries get the first answer back.
The retries still arrive. The difference is that the server recognises them by their key and doesn't charge again.

The goal is not to stop retries. Retries are how systems recover from failure, so you want them. The goal is to make a retry safe.


Why duplicates happen: the ambiguous timeout

Every network call ends in one of three ways:

Outcome What the caller knows Safe to retry blindly?
Success response received It worked No need
Failure response received (e.g. card declined) It didn’t work Yes, as a new attempt
No response (timeout, dropped connection, crash) Nothing. It might have worked No. This is where double charges come from

The third row causes the trouble. A timeout means “I don’t know”, not “it failed”. The request might have died on the way to the server, or the server might have finished the job and the reply was lost on the way back. From the client’s side those two cases look exactly the same.

Customer App Your Server Payment Gateway 1 POST /pay (LKR 5,000) 2 charge ✓ Money moved 3 200 OK (succeeded) 4 Wi-Fi drops, reply never arrives ✗ "Did it fail?" → retry 5 POST /pay (retry) 6 charge again ✗ Charged twice
The server did everything right the first time. The duplicate comes from the retry, and the server can't tell the retry apart from a brand-new payment.

There is no network setting that fixes this. Distributed systems can give you at-most-once delivery (never retry, so some payments are lost) or at-least-once delivery (retry, so some payments arrive twice). “Exactly once” doesn’t exist on the wire. What you can build is exactly-once processing: at-least-once delivery combined with an idempotent receiver.


Real-world failure scenarios

These are the duplicate-charge causes that show up again and again in production payment systems.

# Scenario What actually happens Where the duplicate comes from
1 The double tap A slow spinner, so the user taps Pay again or presses Enter twice Two separate HTTP requests from the browser
2 Mobile network drop The charge succeeds, then the phone switches from Wi-Fi to 4G and the reply is lost The app’s retry logic
3 Refresh / back button The user refreshes the “processing” page and the browser re-POSTs the form The browser
4 Auto-retrying HTTP client Your server’s HTTP client times out at 30s, the gateway answers at 35s, and the client retries Your own infrastructure (SDKs, proxies, load balancers)
5 Queue redelivery A worker charges the card and then crashes before acknowledging the job The queue, because at-least-once delivery runs the job again
6 Webhook redelivery The gateway sends payment.succeeded twice because your 200 reply was slow The gateway, and the order ships twice
7 Blind failover Gateway A times out, so the router retries on gateway B, but A had already charged Your failover logic, and now the duplicate is on two different providers

Scenario 7 is the nastiest. Gateway B can’t know about A’s charge, so the duplicate can’t be detected downstream. The only safe rule is: never fail over on a timeout. Treat a timeout as pending and find out what happened.

Only scenario 1 is “the user’s fault”. Disabling the button fixes that one and none of the others. Idempotency has to live on the server.


The idea: an idempotency key

The client attaches a unique ID to the payment attempt and sends the same ID on every retry:

POST /api/payments HTTP/1.1
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: application/json

{ "order_id": 1042, "payment_method_id": "pm_card_visa" }

The server stores each key with the result it produced. When a key it has already seen arrives, it returns the stored result and doesn’t run the payment again.

Customer App Your Server Payment Gateway 1 POST /pay · key 7c9e save key → processing 2 charge · key 7c9e ✓ Money moved 3 200 OK save response → completed 4 reply lost ✗ 5 retry · same key 7c9e key found, already completed (not called again) 6 same 200 OK (replayed)
Same failure as before, with a key this time. The retry gets the original answer and the gateway is called only once. The key is also passed on to the gateway (step 2).

Designing idempotency keys correctly

The concept is simple. Most real bugs come from getting the details of the key wrong. Here are seven rules.

1. One key per payment intent, not per HTTP request. The key stands for “the customer’s attempt to pay order #1042 with this card”. Every retry of that attempt uses the same key. If you generate a new key inside your retry loop, you have switched the protection off.

2. The client generates it, before the first try. Only the caller knows that two requests are “the same attempt”. Create the key when the checkout starts and keep it (in component state or sessionStorage) so it survives retries and even a page refresh.

3. Make it unique and unguessable. Use a UUID (v4, or v7 if you like time-ordered IDs). Timestamps collide, and auto-increment numbers can be guessed, which would let an attacker replay someone else’s result.

4. Scope it to the owner. Store it as (user_id, key) or (merchant_id, key), not just key, so two customers can never collide and nobody can read another user’s saved response.

5. Bind it to the request body. Store a hash of the payload. If the same key comes back with a different amount or card, that’s a client bug, and you should reject it (422) rather than silently replaying the old result.

6. Give it a lifetime. Keep keys at least as long as any client might still retry. 24 hours is a common choice (Stripe keeps keys for at least that long), and then a scheduled job prunes them.

7. Pass it downstream. Send the same key (or a key derived from it) to the payment gateway. If your server crashes between charging and saving, the gateway itself will de-duplicate your retry.

What about just using the order ID?

It’s tempting, but order_id alone is too coarse. If the first card is declined, the customer must be able to try a different card for the same order, and that’s a new intent. Good options:

Key Verdict
crypto.randomUUID() created at checkout start ✅ Best default for user-initiated payments
order:1042:attempt:2 ✅ Fine if you track attempts on the server
renewal:sub_88:2026-10 ✅ Great for server-side jobs: deterministic, so a re-run of the job produces the same key
order:1042 ⚠️ Blocks a legitimate retry with a different card
Date.now() or a new UUID per HTTP call ❌ Every retry looks new, so there’s no protection
user:17 ❌ Collides across every payment the user makes

The renewal example is worth remembering. For scheduled work there’s no client, so derive the key from the business fact (“subscription 88, October billing”). If the cron job runs twice, both runs produce the same key.


The server-side flow

Every request with a key goes through the same decision tree:

Request arrives Idempotency-Key present? no 400 Bad Request yes INSERT key, status = processing duplicate Load existing record inserted Run payment logic Same request hash? no 422 Key reused yes Save response, status = completed Already completed? no 409 In progress yes Return response Replay saved response The UNIQUE (user_id, key) index makes the INSERT step safe when two requests arrive at the same moment.
Left: the first time a key is seen. Right: every repeat. The status codes follow the IETF Idempotency-Key header draft (400 missing, 422 reused with a different body, 409 still in progress).

The race condition you must avoid

The obvious version is wrong:

// ❌ Check-then-act: two parallel requests can both pass the check
if (! IdempotencyKey::where('key', $key)->exists()) {
    IdempotencyKey::create(['key' => $key]);
    $this->charge(...);   // both requests reach this line
}

A double tap sends two requests about 50ms apart. Both run the SELECT, both see nothing, and both charge. The fix is to let the database decide: attempt the INSERT against a UNIQUE index. Exactly one request can win that insert, however many arrive at once.


Code: building it in Laravel

Step 1 — The table

Schema::create('idempotency_keys', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->uuid('key');
    $table->char('request_hash', 64);             // sha256 of method + path + body
    $table->string('status', 20);                 // processing | completed
    $table->unsignedSmallInteger('response_code')->nullable();
    $table->longText('response_body')->nullable();
    $table->timestamps();

    $table->unique(['user_id', 'key']);           // ← this index is the lock
});

Step 2 — The middleware

namespace App\Http\Middleware;

use App\Models\IdempotencyKey;
use Closure;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class EnsureIdempotency
{
    public function handle(Request $request, Closure $next): Response
    {
        $key = $request->header('Idempotency-Key');

        if (! $key || ! Str::isUuid($key)) {
            return response()->json(['error' => 'A UUID Idempotency-Key header is required.'], 400);
        }

        $hash = hash('sha256', $request->method().$request->path().$request->getContent());

        try {
            // Atomic claim: exactly one request can insert this (user_id, key) pair.
            $record = IdempotencyKey::create([
                'user_id'      => $request->user()->id,
                'key'          => $key,
                'request_hash' => $hash,
                'status'       => 'processing',
            ]);
        } catch (UniqueConstraintViolationException) {
            return $this->handleRepeat($request, $key, $hash);
        }

        $response = $next($request);

        if ($response->getStatusCode() >= 500) {
            // Our own code failed before any money moved (see Step 3), so allow a clean retry.
            $record->delete();
            return $response;
        }

        $record->update([
            'status'        => 'completed',
            'response_code' => $response->getStatusCode(),
            'response_body' => $response->getContent(),
        ]);

        return $response;
    }

    private function handleRepeat(Request $request, string $key, string $hash): Response
    {
        $record = IdempotencyKey::where('user_id', $request->user()->id)
            ->where('key', $key)
            ->firstOrFail();

        if (! hash_equals($record->request_hash, $hash)) {
            return response()->json(['error' => 'This key was already used with a different request.'], 422);
        }

        if ($record->status !== 'completed') {
            return response()->json(['error' => 'The original request is still processing.'], 409)
                ->header('Retry-After', '2');
        }

        return response($record->response_body, $record->response_code)
            ->header('Content-Type', 'application/json')
            ->header('Idempotent-Replayed', 'true');
    }
}
// routes/api.php
Route::post('/payments', [PaymentController::class, 'store'])
    ->middleware(['auth:sanctum', EnsureIdempotency::class]);

Things to notice:

  • Declines are stored too. A 402 card declined is a final answer, so a retry with the same key gets the same decline. To try another card, the client sends a new key.
  • 5xx releases the key, but only because Step 3 guarantees a 5xx means “nothing happened”. If your code can’t promise that, leave the key locked and reconcile (see “Stuck in processing” below).

Step 3 — The controller: pass the key on, and treat timeouts as pending

public function store(PayRequest $request, StripeClient $stripe): JsonResponse
{
    $order = $request->user()->orders()->findOrFail($request->order_id);
    $key   = $request->header('Idempotency-Key');

    $payment = $order->payments()->create([
        'amount_minor'    => $order->total_minor,     // integers, never floats
        'currency'        => $order->currency,
        'status'          => 'pending',
        'idempotency_key' => $key,                    // UNIQUE column: a second safety net
    ]);

    try {
        $intent = $stripe->paymentIntents->create([
            'amount'         => $order->total_minor,
            'currency'       => strtolower($order->currency),
            'payment_method' => $request->payment_method_id,
            'confirm'        => true,
            'metadata'       => ['payment_id' => $payment->id],
        ], [
            'idempotency_key' => $key,                // ← the gateway de-duplicates too
        ]);
    } catch (\Stripe\Exception\CardException $e) {
        $payment->update(['status' => 'failed', 'failure_reason' => $e->getDeclineCode()]);
        return response()->json(['id' => $payment->id, 'status' => 'failed'], 402);
    } catch (\Stripe\Exception\ApiConnectionException) {
        // Timeout or dropped connection: the charge MAY have happened.
        // Do not retry here and do not fail over. The webhook or a status check will settle it.
        return response()->json(['id' => $payment->id, 'status' => 'pending'], 202);
    }

    $payment->update([
        'status'            => $intent->status === 'succeeded' ? 'succeeded' : 'pending',
        'gateway_reference' => $intent->id,
    ]);

    return response()->json(['id' => $payment->id, 'status' => $payment->status], 201);
}

The catch (ApiConnectionException) block is the most important part of the whole article. It turns an ambiguous failure into an honest pending state instead of guessing “failed” and letting someone retry into a double charge.

Step 4 — The client: retry with the same key

Idempotency-Key: 7c9e… (identical on every attempt) Attempt 1 timeout Attempt 2 network error Attempt 3 409 in progress Attempt 4 200 OK (replayed) ✓ wait ~1s wait ~2s wait ~4s (+ random jitter)
Exponential backoff spaces the retries out. Jitter stops thousands of clients from retrying at the same moment after an outage. The key never changes.
// Created ONCE when checkout starts — not inside the retry loop.
let idempotencyKey = crypto.randomUUID();

async function pay(payload, maxAttempts = 5) {
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const res = await fetch('/api/payments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey },
        body: JSON.stringify(payload),
      });

      if (res.status === 409 || res.status >= 500) throw new Error('retryable');

      const result = await res.json();
      if (res.status === 402) idempotencyKey = crypto.randomUUID(); // declined → next try is a NEW intent
      return result;                                                // 201 succeeded, 202 pending, 402 failed
    } catch {
      const backoff = Math.min(1000 * 2 ** attempt, 8000);
      await new Promise(r => setTimeout(r, backoff * (0.5 + Math.random() / 2)));
    }
  }
  return { status: 'unknown' }; // show "checking your payment…" and poll the order — never "failed"
}

Step 5 — Webhooks and queue jobs: de-duplicate by event ID

Gateways deliver webhooks at least once, and queue workers can run the same job twice. The receiver needs two guards: have I seen this event? and is this state change still valid?

public function __invoke(Request $request): Response
{
    $event = $this->verifier->verify($request);           // signature check first, always

    DB::transaction(function () use ($event) {
        // Guard 1: record the event ID. 0 rows inserted = we've handled this delivery before.
        $isNew = DB::table('processed_webhook_events')->insertOrIgnore([
            'event_id'    => $event->id,                  // UNIQUE column
            'received_at' => now(),
        ]);
        if ($isNew === 0) {
            return;
        }

        // Guard 2: conditional state transition. Only pending → succeeded is allowed.
        $updated = Payment::where('gateway_reference', $event->data->object->id)
            ->where('status', 'pending')
            ->update(['status' => 'succeeded']);

        if ($updated === 1) {
            FulfilOrder::dispatch($event->data->object->metadata->payment_id)->afterCommit();
        }
    });

    return response()->noContent();                       // fast 2xx, so the gateway stops retrying
}

Guard 2 matters even with guard 1. A gateway can send two different events (two different IDs) about the same payment, for example charge.succeeded and payment_intent.succeeded. The WHERE status = 'pending' makes the transition itself idempotent: whichever event arrives second updates 0 rows and fulfils nothing.


Defence in depth

No single layer catches everything, so a solid payment system stacks several of them:

1 Client One key per payment attempt, created before the first try. Retry with the same key, backoff + jitter. Disable the button. 2 API middleware Store key + request hash. Replay completed responses. 409 while in progress, 422 when a key is reused with a new body. 3 Database UNIQUE indexes on keys, gateway references and event IDs. State changes only through WHERE status = 'pending'. 4 Gateway Forward the same key so the provider de-duplicates too. Never fail over to another gateway after a timeout. 5 Webhooks & jobs Record processed event IDs. Assume every delivery may be a repeat. Scheduled jobs use deterministic keys (renewal:sub_88:2026-10).
If one layer misses a duplicate, the next one catches it. The database constraints are the last line and the one you can trust most.

Stuck in “processing”?

If the server crashes after claiming a key but before saving the response, the key stays processing and every retry gets 409. Add a sweeper: for keys stuck longer than a minute or two, ask the gateway what happened, using your stored reference or the same idempotency key, and record the real outcome. Don’t delete the key and hope. Deleting it is exactly how double charges come back.


Common pitfalls

Pitfall Why it hurts Fix
New key per retry Every retry looks like a new payment Generate the key once per attempt, outside the retry loop
Check-then-insert Parallel requests both pass the check Rely on a UNIQUE index and catch the violation
Treating timeout as failure The user retries and gets charged twice Return pending and settle via webhook or status query
Failing over on timeout Duplicate charge on two gateways Fail over only on “definitely not received” errors
Key not bound to payload Same key + different amount returns a stale result Store a request hash and return 422 on mismatch
Key not sent to the gateway A crash between charge and save means a retry charges again Forward the key in the gateway’s idempotency header
Webhook handler not idempotent The order ships twice, the wallet is credited twice Event-ID table + conditional UPDATE … WHERE status = 'pending'

A five-point summary

  1. Retries are unavoidable and timeouts are ambiguous. A missing response means “unknown”, not “failed”.
  2. An idempotency key identifies one payment intent. The client creates it once and sends it on every retry.
  3. The server claims the key atomically with a UNIQUE index, stores the response, and replays it for repeats (409 in progress, 422 on mismatch).
  4. Pass the key to the gateway and never fail over on a timeout. Mark the payment pending and let the webhook or a status check settle it.
  5. Make every consumer idempotent too. Webhooks and jobs de-duplicate by event ID and only change state through guarded WHERE status = … updates.

Conclusion

Double charges rarely come from bad arithmetic. They come from the gap between “the server did the work” and “the client heard about it”. You can’t close that gap, because networks will always drop replies. You can make it harmless. Give each payment intent a key, claim it atomically, remember the answer, pass the key downstream, and treat “I don’t know” as pending rather than failed. With that in place, a retry just returns the answer the server already gave.