WooCommerce Cancels Orders While the Customer Is Still Paying
A customer clicks pay, lands on the hosted checkout page, and does what people do: goes to find their card, gets interrupted, switches to their banking app for a 3-D Secure code. Eleven minutes pass. Completely normal.
Meanwhile WooCommerce cancels their order and emails them to say so — while they are still looking at a working payment form.
If they pay anyway, you have money against a cancelled order.
Where the timer comes from
This is wc_cancel_unpaid_orders(), a scheduled task WooCommerce runs on its own. It cancels every
order still in pending that is older than the Hold stock (minutes) setting under WooCommerce
→ Settings → Products → Inventory. That defaults to 60, and it only runs when Manage stock
is enabled.
The setting was built for a different job — stopping abandoned orders from reserving inventory
forever — back when checkout finished on your own site in ninety seconds. It breaks down when
payment happens somewhere else, because the clock keeps running on a page you do not control. A
Stripe Checkout Session carries its own expires_at, and per Stripe’s docs the default is 24
hours. So for most of that window, WooCommerce and Stripe disagree about whether the order exists
— and WooCommerce is the one sending email.
The fix
WooCommerce gives you a filter. Return false and that order is left alone:
add_filter( 'woocommerce_cancel_unpaid_order', function ( $should_cancel, $order ) {
if ( ! $should_cancel || ! $order instanceof WC_Order ) {
return $should_cancel;
}
// Only defend orders paid through the gateway you know about.
if ( 'your_gateway_id' !== $order->get_payment_method() ) {
return $should_cancel;
}
$expires = (int) $order->get_meta( '_your_session_expires', true );
if ( 0 >= $expires ) {
return $should_cancel;
}
// Still payable — leave it alone. 900s of grace covers the gap
// between the session lapsing and the webhook saying so.
return time() >= $expires + 900 ? $should_cancel : false;
}, 10, 2 );
Two things matter. Scope it to your own payment method — a blanket veto keeps abandoned bank-transfer and cash-on-delivery orders alive forever and defeats hold-stock entirely. And drive it off the session’s real expiry, stored when you create the session, not a hard-coded timeout that is wrong in both directions.
The part that will waste your afternoon
Register this in the wrong place and it never runs — no error, no warning, and a manual test that passes.
The unpaid-order sweep runs from cron, and no payment gateway is constructed during it.
WooCommerce builds gateway objects when it needs to render or process a payment. Cron is neither.
So if you hook this inside your gateway’s __construct() — the obvious place, and where most
gateway hooks correctly live — the class is never built during the sweep, the filter is never
added, and every order gets cancelled exactly as before.
Register it from your plugin’s bootstrap, as a static method:
add_filter(
'woocommerce_cancel_unpaid_order',
array( Gateway::class, 'maybe_veto_unpaid_cancel' ),
10,
2
);
Manual testing hides this: visit checkout in a browser and the gateway is constructed, so the filter is registered, and everything looks correct. Only the real cron path behaves differently.
Check your own store: WooCommerce → Settings → Products → Inventory. If Manage stock is on and Hold stock is 60 while customers pay on a hosted page, this is already happening. Those orders are in your list, cancelled, and the customers got an email about it.
PimiPay Stripe is free and handles this by default.