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 does this for you: it stores each Checkout Session’s expiry on the order and vetoes the sweep until that expiry has passed.
Paying through Paddle instead? PimiPay Paddle for WooCommerce opens Paddle’s payment form as an overlay or inline in your own page, so the customer never leaves your store — and since 1.8.0 it does this for you too: an order that has reached the Paddle checkout is kept for a day instead of being swept at 60 minutes, so there is no filter to add. It is $59/year: buy it here.