A deep dive into idempotency for payment APIs and processing, with practical patterns to prevent duplicate charges and lost updates.
A customer clicks "Pay" and the request times out. Did the payment go through? The customer clicks again. Without idempotency, you might charge them twice. This is not a theoretical risk. It happens in production, and it is one of the most damaging bugs a payment system can have.
Idempotency means that performing the same operation multiple times produces the same result as performing it once. For payment systems, this property is not optional. It is fundamental.
The customer's browser or app sends a payment request. The network drops the response. The client retries. Without idempotency, the server creates a second payment.
Your backend sends a charge request to the PSP. The PSP processes it but the response times out. Your backend retries. Without idempotency, the PSP processes a second charge.
The PSP sends a webhook. Your handler processes it but returns a 500 (temporary database error). The PSP retries. Without idempotency, you might fulfill the order twice, send duplicate emails, or create duplicate records.
Your billing scheduler creates a charge for a subscription. The job crashes after creating the charge but before marking it as scheduled. The scheduler runs again and creates a second charge.
The most common pattern for preventing duplicate operations is the idempotency key: a unique identifier attached to each operation that allows the server to recognize and deduplicate retries.
For client-to-server idempotency, the client generates a unique key before the first request and includes it in all retries:
// Client-side: generate key before first attempt
const idempotencyKey = crypto.randomUUID();
async function submitPayment(paymentData) {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Idempotency-Key': idempotencyKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(paymentData),
});
return response.json();
}
Server-side, check the key before processing:
public function createPayment(Request $request): JsonResponse
{
$key = $request->header('Idempotency-Key');
// Check for existing operation with this key
$existing = PaymentIntent::where('idempotency_key', $key)->first();
if ($existing) {
// Return the existing result
return response()->json($existing->toResponse());
}
// Create new payment intent with the key
$intent = DB::transaction(function () use ($request, $key) {
return PaymentIntent::create([
'idempotency_key' => $key,
'amount' => $request->input('amount'),
'status' => 'created',
]);
});
// Process the payment
$this->processPayment($intent);
return response()->json($intent->toResponse());
}
For server-to-PSP calls, derive the idempotency key from the operation's natural identity:
// Natural idempotency key for a subscription charge
$key = "charge:{$subscription->id}:{$billingPeriod->id}";
// Natural idempotency key for a refund
$key = "refund:{$payment->id}:{$amount}:{$reason}";
Most PSPs accept an idempotency key header:
$molliePayment = $mollie->payments->create($params, [
'idempotencyKey' => $key,
]);
If the PSP receives a request with a key it has seen before, it returns the original response instead of creating a new payment.
Idempotency keys are your primary defense, but add database-level protection as a safety net:
-- Prevent duplicate charges for the same billing period
ALTER TABLE charges ADD UNIQUE (subscription_id, period_start, period_end);
-- Prevent duplicate refunds
ALTER TABLE refunds ADD UNIQUE (idempotency_key);
-- Prevent duplicate webhook processing
ALTER TABLE webhook_events ADD UNIQUE (external_event_id);
When updating payment status, use optimistic locking to prevent race conditions:
public function markAsPaid(Payment $payment): bool
{
$updated = Payment::where('id', $payment->id)
->where('status', 'pending') // Optimistic lock condition
->update([
'status' => 'paid',
'paid_at' => now(),
]);
return $updated > 0; // Returns false if already updated
}
Combine idempotency keys with smart retry logic:
class PaymentProcessor
{
public function charge(Subscription $subscription, BillingPeriod $period): Charge
{
$key = "charge:{$subscription->id}:{$period->id}";
return retry(3, function () use ($subscription, $period, $key) {
// Check local records first
$existing = Charge::where('idempotency_key', $key)->first();
if ($existing) {
return $existing;
}
// Create local record
$charge = Charge::create([
'idempotency_key' => $key,
'subscription_id' => $subscription->id,
'amount' => $period->amount,
'status' => 'pending',
]);
// Call PSP with idempotency key
try {
$pspPayment = $this->gateway->charge(
amount: $period->amount,
idempotencyKey: $key,
);
$charge->update([
'external_id' => $pspPayment->id,
'status' => $pspPayment->status,
]);
} catch (Throwable $e) {
$charge->update(['status' => 'failed', 'error' => $e->getMessage()]);
throw $e;
}
return $charge;
}, sleepMilliseconds: 1000);
}
}
Using timestamp-based keys. A key like payment:{userId}:{timestamp} is not idempotent because retries happen at different timestamps.
Using random keys without storage. If the client generates a random key but does not persist it, a page refresh creates a new key and the retry protection is lost.
Checking only one layer. Idempotency at the API level does not protect against duplicate database writes if your code has a race condition between the check and the insert.
Ignoring partial failures. The PSP charged the customer but your database update failed. On retry, the idempotency key at the PSP returns success, but your local record still shows pending. Handle this by reconciling the PSP state on each attempt.
Short TTL on idempotency keys. Some PSPs expire idempotency keys after 24-48 hours. If your retry window is longer than this, the PSP may process a duplicate. Align your retry window with the PSP's key retention period.
Write explicit tests for duplicate operations:
public function test_duplicate_payment_creation_returns_same_result(): void
{
$key = 'test-key-123';
$first = $this->postJson('/api/payments', $data, [
'Idempotency-Key' => $key,
]);
$second = $this->postJson('/api/payments', $data, [
'Idempotency-Key' => $key,
]);
$first->assertOk();
$second->assertOk();
// Same payment returned, not a duplicate
$this->assertEquals(
$first->json('id'),
$second->json('id'),
);
// Only one payment exists
$this->assertEquals(1, Payment::count());
}
Idempotency is the invisible guardian of payment system integrity. Implement it at every boundary: client to server, server to PSP, webhook handler, and scheduler. Use idempotency keys, database constraints, and state checks together. Test explicitly for duplicate operations. Your customers' wallets depend on it.
Whether you're modernizing your infrastructure, navigating compliance, or building new software - we can help.
Book a 30-min Call