Back to all terms
Payment
Paymentsintermediate

Double-Charge Prevention

Implementing safeguards at the application, API, and database layers to prevent customers from being charged twice for the same transaction due to retries, race conditions, or user double-clicks.

Also known as: duplicate payment prevention, duplicate charge guard, payment deduplication

Description

Double-charge prevention ensures customers are never billed twice for the same purchase, which is one of the most critical correctness requirements in any payment system. Duplicate charges can occur from multiple sources: the user double-clicking a payment button, network timeouts causing the client to retry a request, server-side retry logic re-submitting a payment call, concurrent requests from multiple browser tabs, or mobile app backgrounding and resuming a payment flow. Each attack vector requires a specific mitigation strategy at the appropriate layer.

At the frontend layer, disable the payment button immediately on click and implement client-side debouncing. At the API layer, use Stripe's idempotency keys on all payment creation calls to ensure retried requests return the cached response. At the application layer, implement a state machine on your order/transaction model that enforces valid transitions (draft -> processing -> paid) and rejects payment attempts for orders not in the correct state. Use optimistic locking (UPDATE orders SET status = 'processing', version = version + 1 WHERE id = ? AND status = 'draft' AND version = ?) to handle concurrent requests safely.

At the database layer, add unique constraints that make duplicate records physically impossible. A unique index on (order_id, charge_type) in your payments table ensures that even if concurrent requests pass the application checks, only one can insert a payment record. Combine this with Stripe's PaymentIntent model, which is inherently idempotent: a single PaymentIntent can only result in one successful charge. Create the PaymentIntent when the order enters the checkout flow and reuse it across retries, rather than creating a new PaymentIntent on each attempt.

Prompt Snippet

Prevent double charges with defense in depth: disable the submit button on click and debounce the handler on the frontend; generate an idempotency key from the order ID (idempotencyKey: `order_${orderId}_payment`) on stripe.paymentIntents.create(); enforce order state transitions with optimistic locking (UPDATE orders SET status = 'processing' WHERE id = ? AND status = 'pending_payment' RETURNING id); and add a UNIQUE constraint on payments(order_id, status) WHERE status = 'succeeded' as the final safeguard. Create a single PaymentIntent per order at checkout initiation and reuse its ID across retries, rather than creating new PaymentIntents on each attempt. Log all payment attempts (including duplicates) in a payment_attempts audit table for debugging.

Tags

deduplicationidempotencyreliabilitypaymentssafetyconcurrency