WooCommerce Checkout Block Creating Duplicate Orders

WooCommerce checkout block placing the same order twice and charging cards twice? Here is why the second submit fires and the idempotency fix that holds.
WooCommerce Checkout Block Creating Duplicate Orders
WooCommerceCheckoutWordPress
June 15, 20266 min read1080 words

The Problem

I ran into this on a client project last week. A fashion store on WooCommerce 10.8 with the block-based checkout started getting duplicate orders right after a busy weekend. Same customer, same cart, two orders thirty to ninety seconds apart, and in about a third of the cases Stripe had charged the card twice. Customer support spent the Monday morning issuing refunds.

The order table looked like this in HPOS:

id    | status     | total | created_at
------+------------+-------+---------------------
89201 | processing | 184.0 | 2026-06-13 14:02:11
89202 | processing | 184.0 | 2026-06-13 14:02:48

Same billing email, same shipping address, same line items, two Stripe pi_* payment intents, two charges. The customer reported clicking "Place Order" once and seeing a loading spinner that "stuck" for a few seconds before the thank-you page rendered. No JavaScript error in the console. No PHP error in the WooCommerce log. The store had been on the checkout block for months. Nothing about the deploy that morning had touched the checkout.

Why It Happens

The checkout block submits through wc/store/v1/checkout, the Store API endpoint. That call does two things in sequence: it creates the order row, and it asks the active payment method to confirm the payment intent. On a slow network or a slow third-party script, the round trip can take three to six seconds. The block does disable the place-order button while the request is in flight, but the disable is purely client-side. If the user-agent does anything that retries the POST — service worker replay, an unstable mobile connection that resends after the timeout, a backgrounded tab that hydrates twice — a second call goes out before the first response arrives.

The Store API does not enforce idempotency by default. Each call creates a new draft order. The payment method runs again, the second payment intent is confirmed against the saved card, and you end up with two orders and two charges. The first call eventually returns, the customer sees the thank-you page for order 89201, and never knows 89202 exists until the bank statement.

The block's Store API checkout reference documents an Idempotency-Key header that the server honours, but the block does not send it. That is the gap. Plenty of older Stripe gateway extensions also miss this because the legacy shortcode checkout used a nonce that effectively single-shotted the submit.

This is the root cause. The fix is to send a stable idempotency key on every submit so the second POST returns the first order instead of creating a new one.

The Fix

Two layers. First, attach an idempotency key client-side. Second, harden the server side so a retry that arrives without a key still cannot create a duplicate.

Step 1: Inject an Idempotency-Key header on every checkout POST. Hook into the block's data store to generate a per-submit UUID and add it to the fetch:

import { addAction } from '@wordpress/hooks';
import apiFetch from '@wordpress/api-fetch';

const checkoutKey = () => {
  const cached = window.sessionStorage.getItem('wc_checkout_idem');
  if (cached) return cached;
  const k = crypto.randomUUID();
  window.sessionStorage.setItem('wc_checkout_idem', k);
  return k;
};

apiFetch.use((options, next) => {
  if (options.path && options.path.includes('/wc/store/v1/checkout')) {
    options.headers = {
      ...(options.headers || {}),
      'Idempotency-Key': checkoutKey(),
    };
  }
  return next(options);
});

addAction('experimental__woocommerce_blocks-checkout-set-active-payment-method', 'theme/clear-idem', () => {
  window.sessionStorage.removeItem('wc_checkout_idem');
});

The key lives for the duration of one checkout attempt. As soon as the customer changes payment method or successfully lands on the thank-you page, the session storage entry is cleared so the next order has its own key.

Step 2: Make the server return the first order if a duplicate key arrives. Register a small filter that short-circuits the checkout request when the same key is seen twice within a five-minute window:

add_filter('rest_pre_dispatch', function ($result, $server, $request) {
    if ($request->get_route() !== '/wc/store/v1/checkout') {
        return $result;
    }
    $key = $request->get_header('idempotency_key');
    if (! $key) {
        return $result;
    }

    $cache_key = 'wc_idem_' . md5($key);
    $existing  = get_transient($cache_key);
    if ($existing && isset($existing['order_id'])) {
        $order = wc_get_order($existing['order_id']);
        if ($order) {
            return rest_ensure_response($existing['payload']);
        }
    }
    return $result;
}, 10, 3);

add_action('woocommerce_store_api_checkout_order_processed', function ($order) {
    $request = WC()->api->request ?? null;
    $key     = $request ? $request->get_header('idempotency_key') : null;
    if (! $key) {
        return;
    }
    set_transient('wc_idem_' . md5($key), [
        'order_id' => $order->get_id(),
        'payload'  => [
            'order_id' => $order->get_id(),
            'status'   => $order->get_status(),
        ],
    ], 5 * MINUTE_IN_SECONDS);
});

The transient stores the response payload against the key. If the same POST arrives again within five minutes, the filter returns the cached response without creating a new order or confirming a second payment intent. Adjust the payload to match whatever your gateway needs to redirect cleanly.

Step 3: Verify the dedupe is working. Open the network tab on a staging checkout, place a test order, then in DevTools right-click the /wc/store/v1/checkout request and pick Replay XHR. The second response should arrive in single-digit milliseconds and contain the original order_id. Query HPOS to confirm only one row was written:

SELECT id, status, total_amount, date_created_gmt
FROM wp_wc_orders
WHERE billing_email = 'test@example.com'
ORDER BY id DESC
LIMIT 5;

One row per checkout. If you see two, the idempotency key was not attached to the second request, which usually means the apiFetch middleware loaded after the block hydrated. Move the registration script to load with wp_enqueue_script priority 5 and re-test.

The Lesson

The checkout block trusts the client to submit once. On a flaky connection or a backgrounded tab, that trust breaks and you end up paying for it in refunds. Attach a stable Idempotency-Key on the client, cache the response on the server, and let the second submit no-op. The Store API supports it. The block does not wire it up by default.

If your store is quietly producing duplicate orders and your support inbox is filling with charge-twice complaints, that is the kind of audit I run on client checkouts. See my services. For a related Stripe issue on the same block, read WooCommerce Stripe 3D Secure loop fix.

Refunding duplicate charges every Monday? Let me fix the checkout.

Back to blogStart a project