---
title: "WooCommerce Cancels Orders While the Customer Is Still Paying"
description: "WooCommerce cancels pending orders after 60 minutes when stock management is on — shorter than a hosted checkout session lives. Here is the one-filter fix, and the mistake that makes it silently do nothing."
pubDate: 2026-08-21
source: https://pimipay.com/blog/woocommerce-cancels-unpaid-orders/
---

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:

```php
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:

```php
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](/stripe-checkout-for-woocommerce/) 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](/blog/paddle-inline-checkout-wordpress/), 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](https://portal.w4dev.com/checkout/?add-to-cart=8746&utm_source=pimipay.com&utm_medium=referral&utm_content=blog).