How I Traced Duplicate WooCommerce Orders to a Race Condition Between Webhooks and Checkout
· 13 min read
A store owner messaged me on a Friday afternoon with the kind of problem that ruins weekends. Customers were emailing in saying they had been charged twice. Some had two identical orders in their account. Others had one order but two charges on their card statement. The store was processing around 200 orders a day through Stripe, and over the previous week, roughly 15 customers had reported double charges.
The owner had already refunded them manually, but the problem was getting worse, not better. And every duplicate charge was costing a double transaction fee that Stripe does not refund.
Confirming the problem
First thing I did was pull up the WooCommerce orders list and sort by customer email. I was looking for two orders with the same email address, same products, and timestamps within a few minutes of each other.
I found them quickly. Pairs of orders, usually 1 to 90 seconds apart, identical cart contents, identical billing details. Some pairs both showed as "Processing." Others had one "Processing" and one "Failed" or "Pending Payment."
Then I checked the Stripe dashboard. For most of the duplicate pairs, there were genuinely two separate successful charges. Two payment intents, two captures, two fees. This was not a display issue in WooCommerce. Customers were actually being debited twice.
Cause 1: the Place Order button was not being disabled
I opened the checkout page in a browser and placed a test order on the staging copy. The Stripe payment sheet processed, there was a delay of about four seconds while the payment was being confirmed, and during that entire window the "Place Order" button was still clickable.
On a slow connection or with a hesitant customer, four seconds is an eternity. A customer clicks, nothing happens for a moment, they click again. Two payment intents get created. Two orders get placed.
The theme was overriding the default WooCommerce checkout template and had stripped out the JavaScript that disables the button during submission. Classic.
The fix was adding a small script to the checkout page:
jQuery(function ($) {
var $form = $('form.checkout');
$form.on('checkout_place_order', function () {
var $btn = $form.find('#place_order');
if ($btn.data('submitting')) {
return false;
}
$btn.data('submitting', true);
$btn.prop('disabled', true).css('opacity', '0.5');
return true;
});
$(document.body).on('checkout_error', function () {
var $btn = $form.find('#place_order');
$btn.data('submitting', false);
$btn.prop('disabled', false).css('opacity', '1');
});
});
Enqueue it in your theme or a custom plugin:
add_action('wp_enqueue_scripts', function () {
if (is_checkout()) {
wp_enqueue_script(
'disable-double-click',
get_stylesheet_directory_uri() . '/js/disable-double-click.js',
['jquery'],
'1.0',
true
);
}
});
This disables the button on first click and re-enables it only if WooCommerce fires a checkout error. It does not interfere with the payment flow.
Cause 2: Stripe webhook and return URL racing each other
Disabling the button reduced the duplicates, but did not eliminate them. I was still seeing pairs of orders where the timestamps were identical to the second. No human is clicking that fast. Something else was going on.
I dug into the WooCommerce order notes for one of these pairs. Both orders had a note saying "Stripe payment complete" with the same Stripe payment intent ID. Two WooCommerce orders, one Stripe payment. Only one charge on the card, but two orders in the system, and WooCommerce had sent two confirmation emails.
The culprit was a race condition between two parallel processes:
-
The return URL flow: after the customer pays, Stripe redirects the browser back to the WooCommerce "order received" endpoint. WooCommerce processes the return, checks the payment intent status via the Stripe API, sees it is succeeded, and creates or updates the order.
-
The webhook flow: at essentially the same time, Stripe sends a
payment_intent.succeededwebhook to the site. The webhook handler also checks the payment intent, sees it succeeded, and creates or updates the order.
Both processes hit WooCommerce within milliseconds of each other. If the return URL handler creates the order first but has not yet written the Stripe payment intent ID to the database, the webhook handler cannot find an existing order for that payment intent, so it creates a second one.
This is a known issue with WooCommerce payment gateway plugins. The Stripe plugin has improved its handling over the years, but certain configurations still trigger it, particularly when:
- The site is on slow hosting where database writes take longer
- Object caching is not properly configured, so there is no shared state between the two PHP processes
- An older version of the Stripe gateway plugin is installed
The immediate fix was updating the WooCommerce Stripe Gateway plugin to the latest version (8.9.x at the time), which includes better idempotency handling with database-level locking:
wp plugin update woocommerce-gateway-stripe --path=/var/www/html
I also verified the webhook endpoint was correctly configured in the Stripe dashboard. The plugin registers its own endpoint at /?wc-api=wc_stripe, and there should be exactly one webhook pointing there. Duplicate webhook endpoints in Stripe will guarantee duplicate processing.
To check the local plugin webhook settings:
wp option get woocommerce_stripe_settings --path=/var/www/html --format=json | python3 -m json.tool | grep -i webhook
This only shows what WooCommerce has stored locally. You also need to check the Stripe dashboard directly under Developers > Webhooks — that is the only place you can see whether duplicate webhook endpoints exist. If two endpoints both point to /?wc-api=wc_stripe, delete the extra one.
If you are running an older version of the plugin and cannot update immediately, you can add a guard at the payment completion stage that catches duplicates before they result in two fulfilled orders. The race happens when both the webhook handler and the return URL handler call woocommerce_payment_complete on separate orders for the same payment intent, so that is where the check needs to run:
add_action('woocommerce_payment_complete', function ($order_id) {
$order = wc_get_order($order_id);
$intent_id = $order->get_meta('_stripe_intent_id');
if (empty($intent_id)) {
return;
}
$existing = wc_get_orders([
'meta_key' => '_stripe_intent_id',
'meta_value' => $intent_id,
'status' => ['wc-processing', 'wc-completed', 'wc-on-hold'],
'exclude' => [$order_id],
'limit' => 1,
]);
if (!empty($existing)) {
$order->update_status('cancelled', sprintf(
'Duplicate detected — payment intent %s already fulfilled in order #%d.',
$intent_id,
$existing[0]->get_id()
));
}
}, 5);
This hooks into the point where both the webhook and the return URL converge — when WooCommerce marks a payment as complete. If another order already exists for the same Stripe payment intent, the latecomer gets cancelled automatically. The low priority (5) helps it run early, before confirmation emails are sent.
Cause 3: a PHP fatal error was preventing order completion
The last batch of duplicates had a different pattern. In these cases, the first order was stuck at "Pending Payment" and the second order, placed minutes later by the same customer, was "Processing." Only one Stripe charge existed. The customer had not actually been charged twice, but they thought they had, because they got error messages and tried again.
I checked the PHP error log:
grep -i "fatal\|error" /var/log/php-fpm/error.log | grep -i "checkout" | tail -20
There it was. A fatal error from a shipping tracking plugin that was hooking into woocommerce_checkout_order_processed and calling an undefined method. The payment went through on Stripe's side, but WordPress died before WooCommerce could update the order status from "Pending" to "Processing." The customer saw a white page or a generic error, assumed the payment had failed, and tried again. The second attempt succeeded because the fatal error was intermittent, triggered only when a specific product with specific shipping requirements was in the cart.
The fix:
wp plugin deactivate advanced-shipment-tracking --path=/var/www/html
After confirming the checkout worked without it, I checked for an updated version of the plugin, found one that fixed the compatibility issue, and reactivated it:
wp plugin update advanced-shipment-tracking --path=/var/www/html
wp plugin activate advanced-shipment-tracking --path=/var/www/html
Then I went through all the "Pending Payment" orders from the affected period and reconciled them against Stripe:
wp wc order list --status=pending --after=2026-03-01 --format=table --path=/var/www/html --user=1
For each one, I checked the Stripe dashboard to see if payment had actually been captured. Several had. Those orders needed their status manually updated to "Processing" and the duplicate orders cancelled and refunded.
Preventing duplicate orders going forward
After fixing all three causes, I put monitoring in place to catch any future duplicates early:
1. A daily WP-CLI check for duplicate orders:
wp db query "
SELECT o1.id AS order_1, o2.id AS order_2,
o1.date_created_gmt, o1.total_amount
FROM wp_wc_orders o1
JOIN wp_wc_orders o2
ON o1.billing_email = o2.billing_email
AND o1.total_amount = o2.total_amount
AND o1.id < o2.id
AND TIMESTAMPDIFF(SECOND, o1.date_created_gmt, o2.date_created_gmt) < 300
WHERE o1.date_created_gmt > DATE_SUB(NOW(), INTERVAL 1 DAY)
AND o1.status IN ('wc-processing', 'wc-completed')
AND o2.status IN ('wc-processing', 'wc-completed')
" --path=/var/www/html
This finds pairs of orders from the same email, same total, created within 5 minutes of each other in the last 24 hours. I run this via cron and pipe the output to a Slack notification. Any matches get investigated immediately.
2. PHP error monitoring on checkout:
I added a specific error log monitor for fatal errors hitting WooCommerce checkout endpoints. Any fatal error on checkout is a high-priority alert, because it almost always means payments are succeeding but orders are not completing.
3. Stripe webhook delivery monitoring:
In the Stripe dashboard under Developers > Webhooks, I set up an alert for webhook delivery failures. If the webhook endpoint starts failing, the race condition between webhook and return URL shifts entirely to the return URL flow, which is less reliable.
The numbers after the fix
In the week before the fixes, the store had 14 confirmed duplicate charges across roughly 1,400 orders. That is a 1% duplicate rate. After all three fixes were deployed, the store processed over 3,000 orders across the following two weeks with zero duplicates.
The total cost of the duplicates to the store owner: 14 refunds plus 28 Stripe transaction fees that were not recoverable. Around £180 in fees alone, plus the customer trust damage.
If you are seeing duplicate orders
Start with these checks in order:
- Place a test order on your live checkout (use Stripe test mode or a small amount). Watch whether the Place Order button disables during processing.
- Check your WooCommerce order notes on the duplicate pairs. If both orders reference the same Stripe/PayPal transaction ID, you have a webhook race condition. If they have different transaction IDs, your customers are submitting the form twice.
- Check your PHP error logs around the timestamps of the duplicate orders. Fatal errors during checkout are the silent killer.
- Check your payment gateway plugin version. If you are more than two major versions behind, update it. Gateway plugins receive critical fixes for exactly these kinds of race conditions.
- Check your Stripe/PayPal webhook configuration. You should have exactly one active webhook endpoint per payment gateway. Duplicate endpoints mean duplicate processing.
Duplicate orders are one of those problems where the fix is usually straightforward once you know the cause, but the cause is almost never what the store owner thinks it is. It is rarely "WooCommerce is broken." It is usually a combination of a slow checkout, a race condition, and an unrelated PHP error working together to create a perfect storm.
Need help tracking down duplicate orders on your store? My WooCommerce maintenance plans include payment gateway monitoring and checkout health checks. Or if you are dealing with active duplicate charges right now, get in touch for emergency support.
