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 - 100takes 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.
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.
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.
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:
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 declinedis a final answer, so a retry with the same key gets the same decline. To try another card, the client sends a new key. 5xxreleases the key, but only because Step 3 guarantees a5xxmeans “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
// 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:
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
- Retries are unavoidable and timeouts are ambiguous. A missing response means “unknown”, not “failed”.
- An idempotency key identifies one payment intent. The client creates it once and sends it on every retry.
- The server claims the key atomically with a
UNIQUEindex, stores the response, and replays it for repeats (409in progress,422on mismatch). - Pass the key to the gateway and never fail over on a timeout. Mark the payment
pendingand let the webhook or a status check settle it. - 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.