ddtcorex

01 Oct 2026 · 5 min read

Idempotency and the double-charge problem in order placement

Guard Magento order placement against double charges with idempotency keys, quote locking and payment retry handling

Part 5 of the Checkout & Payments series — the customer clicked once, the gateway charged twice.

The order that billed twice

A merchant called us after a flash sale produced a cluster of angry tickets: customers charged twice for the same cart, with two orders minutes apart or one order and two gateway captures. The cause was never malice. It was retries. A shopper double-clicked Place Order on a slow connection. A mobile browser resubmitted the POST after a timeout. A payment webhook arrived twice. Magento placed the order path more than once, and each pass asked the gateway for money. The fix is a single principle: placing an order must be idempotent, so repeating the same request changes nothing after the first success.

Why this matters

Order placement sits at the intersection of three systems that all retry: the browser, Magento, and the payment gateway. The browser resubmits forms. The checkout API client retries on timeout. The gateway redelivers webhooks. If any repeated call creates a new order or a new capture, money moves twice. The damage is not just the refund cost. It is the support load, the chargeback risk, and the lost trust when a customer sees two charges and assumes fraud.

Magento gives you most of the pieces: quote state, reserved order IDs, payment transaction IDs, and order state guards. Idempotency is the discipline of wiring them together so every entry point checks whether the work already happened before doing it again.

How it works

Start with the key. Every place-order attempt needs an idempotency key that survives retries: the quote's masked ID plus a client-generated token is the standard pair. The frontend generates a UUID when the checkout loads the review step and sends it with each place-order call. The backend stores it on the quote payment's additional information and on the order payment after placement. Two requests with the same key are the same intent; two with different keys are two intents.

Guard the quote first. A quote that already produced an order must refuse a second placement:

if ($quote->getReservedOrderId() && $this->orderRepository->get($quote->getReservedOrderId())) {
    return $this->orderRepository->get($quote->getReservedOrderId());
}

Better still, lock the quote during placement. Set is_active = 0 in the same transaction that creates the order, or use a short-lived lock row keyed by quote_id. Without the lock, two concurrent requests can both read an active quote, both pass validation, and both create orders before either commits.

Guard the gateway call next. Before calling authorize or capture, check the payment for an existing transaction of the same type:

$existing = $payment->getTransaction($transactionId);
if ($existing && $existing->getIsClosed()) {
    return $existing;
}

Pass the idempotency key to the gateway when the API supports it. Stripe, Adyen, and Braintree all accept an idempotency key header or field; store the exact key sent in additional_information so a retry reuses it instead of minting a new one. The gateway then collapses duplicate requests server side even when Magento sends the HTTP call twice.

Handle the frontend honestly. Disable the Place Order button on first click, show a pending state, and poll order status instead of letting the shopper click again. On timeout, do not auto-retry with a fresh key. Re-query with the same key: first GET /V1/carts/mine/payment-information state, then order lookup by reserved_order_id, and only then a same-key retry. Most double charges come from a retry path that invents a new identity for an old intent.

Webhooks need the same treatment. Verify the gateway signature, then check whether the referenced transaction_id already exists on the order payment before invoicing. Process webhook payloads through a queue consumer that locks on order_id, so two redeliveries racing each other serialize instead of double-invoicing.

Pitfalls

  • Relying on the button alone. Disabling Place Order in JS stops double clicks, not resubmitted POSTs, API retries, or webhook redelivery. The server-side key check is the real guard.
  • Minting a new gateway key per retry. If each retry generates a fresh idempotency key, the gateway sees distinct requests and charges each one. Generate once per checkout attempt, persist it, reuse it.
  • Checking the order but not the transaction. Finding no duplicate order and stopping there misses the case that hurts most: one order, two captures. Always check payment transactions before calling the gateway.
  • Reserving the order ID too late. If reserved_order_id is assigned after payment, two concurrent placements have nothing to collide on. Reserve it when checkout starts, persist it on the quote, and enforce a unique constraint so the second insert fails loudly.
  • Treating webhook handling as synchronous. Processing webhooks inline in the controller invites timeouts and duplicate delivery. Queue them, acknowledge fast, and dedupe in the consumer.

Field check on a live store

Pull last month's orders and join against payment transactions: any order with two closed captures for one authorization is a double charge, and any pair of orders sharing a quote ID is a duplicate placement. For each hit, check whether the two attempts carried the same idempotency key. If the keys differ, the retry path is minting fresh identities and that is the first fix. If the keys match but two charges still happened, the backend compared keys in the wrong place or not at all. Add the quote lock, the transaction lookup, and the gateway key reuse, then replay the scenario in staging with a throttled connection and a forced double submit. One key, one order, one charge.

Closing

Double charges are a retry problem wearing a payment mask. One stable key per checkout intent, a quote that locks during placement, and a gateway call that checks before it charges turn every duplicate delivery into a harmless no-op.

Next in this series:

magento2-checkout-shipping-carriers

magento2paymentsorders