Skip to content
Blog

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.