Skip to content
System nominalUTC --:--:--Articles 080

Triaging a WooCommerce Checkout That Dies at Payment

A pending order and a request that died before the order existed need different recovery. Here is how to tell which one you are looking at.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Topic
WOO
Section
WooCommerce & Storefront
Published
16 OCT 2026
Reading time
8 MIN
Words
1988W
Technical cover graphic for Triaging a WooCommerce Checkout That Dies at Payment
Generated schematic — WOO series, REV 2.6.

The report is always the same shape. A customer says their card was declined, or that they pressed Place order and got a blank page, and the shop owner cannot find the order. Sometimes there is no order because the request never got that far. Sometimes there is one and nobody looked in the right place. The recovery is different in each case, and the first ten minutes of triage decide which one you are dealing with.

Everything below was produced on a WooCommerce 11.2.1 tree on WordPress 7.0.7 and PHP 8.5.6, driving the shortcode checkout from WP-CLI with a test gateway. Where the flow depends on a real gateway's timing, I killed the request mid-call rather than pretending a sleep is the same thing.

The order exists before the payment is attempted

On the shortcode checkout, WC_Checkout::process_checkout() runs a fixed sequence. It checks the nonce, checks the cart is not empty, validates the posted data, creates or finds the customer, creates the order, and only then calls the gateway. The order creation happens at $this->create_order( $posted_data ), and the log line that records it is step four.

// includes/class-wc-checkout.php, condensed to the order of operations
$this->process_customer( $posted_data );
$order_id = $this->create_order( $posted_data );
$order    = wc_get_order( $order_id );

do_action( 'woocommerce_checkout_order_processed', $order_id, $posted_data, $order );

if ( apply_filters( 'woocommerce_cart_needs_payment', $order->needs_payment(), WC()->cart ) ) {
    $this->process_order_payment( $order_id, $posted_data['payment_method'] );
} else {
    $this->process_order_without_payment( $order_id );
}

Two lines in that excerpt do most of the diagnostic work, and both are in the source.

The first is the call to process_order_payment(), which logs [Shortcode #6A] Order payment processed successfully on the way out and nothing at all when the gateway does not produce a success result. If your order step log ends at step five, the payment was attempted and the flow stopped there. If the log ends at step four, the request died between creating the order and the hook, which is usually a fatal in a plugin listening on woocommerce_checkout_order_processed.

The second is a comment inside process_order_payment() that a lot of people have read and fewer have understood. Before it calls the gateway, it stores the order ID in the session and saves the session immediately, because a hanging gateway means the request never finishes and a session that is never written means a duplicate order when the customer tries again.

// Store Order ID in session, so it can be re-used after payment failure.
WC()->session->set( 'order_awaiting_payment', $order_id );

// We save the session early because if the payment gateway hangs
// the request will never finish, thus the session data will never be saved,
// and this can lead to duplicate orders if the user submits the order again.
WC()->session->save_data();

That is the safeguard, and it only works if the gateway hangs while the process is still alive. When PHP is killed, or the FPM worker is recycled, or the request hits a 30-second proxy timeout, the order and the session value are already on disk. What is missing is everything the gateway would have written back, and the order stays pending forever with no alert.

Was an order created at all?

Answer this before you look at the customer's complaint, the gateway dashboard, or the logs. There is a query for it, and it takes one second.

SELECT p.ID, p.post_status, pm.meta_value AS debug_source
FROM wp_posts p
JOIN wp_postmeta pm ON pm.post_id = p.ID AND pm.meta_key = '_debug_log_source'
WHERE p.post_type = 'shop_order'
ORDER BY p.ID DESC;

The meta key is the useful part. WooCommerce writes _debug_log_source onto the order as soon as it has an order object to attach it to, and it deletes that meta again when the checkout reaches a known final step. On a successful order, the meta is gone by the time the customer sees the thank-you page. On an abandoned one, the meta is still there and it tells you which log file to open.

#17 status=wc-pending log=place-order-debug-a5b932ba

Read that line as: this order was created, the customer's request got as far as the checkout process, and the flow never reached a final step. Now open wp-content/uploads/wc-logs/place-order-debug-a5b932ba-*.log and count the steps. Five lines and no sixth means the request died inside process_payment(). Six with a failure code means the gateway answered and refused.

If the query returns nothing and the customer still says money left their account, you are in the other case, and the next section is for you. If it returns several rows, read the timestamps before you read anything else, because two of them an hour apart usually mean the same customer tried twice and the second attempt made a new order.

A pending order is recoverable, and you should say so out loud

A declined card does not delete the order. Running the same checkout with a gateway that adds an error notice and returns array( 'result' => 'failure' ) leaves this:

orders before: 0
elapsed: 0.08s
orders after: 1
notices: ["Your card was declined (code 51)."]

newest: #16 status=pending total=49.00 needs_payment=yes date_paid=none
meta _debug_log_source=place-order-debug-8d50313b

That order is a real order in a state that can be paid. The customer can be sent a payment link, or they can return to the checkout and finish. If you delete it as junk, you throw away the record of what they were buying at the price they agreed to, and you make the support conversation harder. The metaphor to keep in your head is a paper invoice on the counter, not a failed request. It exists, it is unpaid, and the customer is one step away from resolving it.

The failure this state hides is subtler. The order is wc-pending, which is the same status as a brand new order that nobody has touched. Order lists and reports do not separate "waiting for the customer to pay" from "the payment attempt died". A shop with a slow gateway accumulates a column of pending orders that nobody clears, and each one is a customer who believes they bought something.

A different status, a different tool

This is the case for a custom order status or a small plugin that moves pending orders to a holding state after the gateway returns a failure. WooCommerce does not do it for you, and the default admin list will not tell the two populations apart.

The request that died before there was an order

There is a state where the customer is charged and no order exists at all. It does not come from process_checkout() on the shortcode path, because the order is created first there. It comes from storefronts whose checkout defers order creation, and from plugins that create a draft order, charge, and crash before promoting it.

Block checkout is the common one now. Its Store API route creates an order with the internal status wc-checkout-draft and promotes it to a real status at place-order time. Since WooCommerce 10.8 that draft row is only created on the POST that places the order, so a form interaction that never completes leaves nothing behind, which is the point. But once the POST starts, a draft row does exist, and it carries a status that is not one of the seven core statuses.

// src/Enums/OrderInternalStatus.php -- the seven, and what is not in the list
const PENDING    = 'wc-pending';
const PROCESSING = 'wc-processing';
const ON_HOLD    = 'wc-on-hold';
const COMPLETED  = 'wc-completed';
const CANCELLED  = 'wc-cancelled';
const REFUNDED   = 'wc-refunded';
const FAILED     = 'wc-failed';

wc-checkout-draft is registered through the wc_order_statuses filter and is excluded from analytics, from the customer's account order list, and from the default admin filter. A draft order that should have become a payment record is invisible in the places people look.

It also has an expiry. WooCommerce schedules woocommerce_cleanup_draft_orders, which selects draft orders whose date_modified is older than a day, in batches of twenty, and deletes each one permanently with $order->delete( true ). If your gateway charges the customer and your code dies before the status changes, the only record of the charge becomes a draft row that a daily job is trying to erase. If the charge is visible in the gateway dashboard, that matters little for reconciliation, but the customer's order history and any email tied to the order ID are gone.

Reproduce the death before you guess at it

You cannot triage a timeout from a screenshot, and you cannot reproduce one by adding sleep( 5 ) to a gateway either, because in my run the sleep was still running when I needed the answer and the request was still alive. A sleep proves the order exists during a slow call. It does not prove what the store looks like after the call is gone.

To get the second state, put a small test gateway on the site that marks the order and then blocks, start the checkout from the command line, and kill the process while it is inside process_payment(). That is what I did, and the two inspections either side of the kill came back identical: one pending order, one session value, one log file with five steps in it. The state does not change when the process dies, which is exactly why nobody notices at the time.

One configuration detail is worth checking while you are there. process_checkout() calls wc_set_time_limit( 0 ) early, so the PHP time limit is not what kills the request. The timeout that matters belongs to the web server, the reverse proxy, or the host's FPM request_terminate_timeout, and it will not appear in the WooCommerce logs at all. Ask for that number before you ask for the gateway's latency figures.

Retrying a checkout is only safe while the cart is unchanged

The resume logic is in create_order(), and it is narrower than people assume. It looks at order_awaiting_payment from the session, and it reuses that order only when the stored cart hash matches the current one and the order is still pending or failed.

if ( $order && $order->has_cart_hash( $cart_hash )
    && $order->has_status( array( OrderStatus::PENDING, OrderStatus::FAILED ) ) ) {
    do_action( 'woocommerce_resume_order', $order_id );
    $order->remove_order_items();
} else {
    $order = new WC_Order();
}

I ran both branches. Seeding the session with the pending order and retrying an identical cart returned the same order ID and left the store with one order. Adding a second unit to the cart first changed the hash, and create_order() returned a new ID, leaving the store with two.

cart hash now: dc9a88db2fa658191b7efab1b46dc207 order cart hash: dc9a88db2fa658191b7efab1b46dc207
create_order returned: 13
orders now: 1

cart hash with qty 2: 246cc209b91c8e4206fa036c5e414b5b
create_order returned: 14 (13 means it resumed; anything else means a new order)
orders now: 2

So the customer who hits a gateway timeout, waits, then retries with a different quantity now has two pending orders and a charge that may match either or neither. That is the expensive outcome, and it is the reason the timeout matters more than the plain decline. A decline has unambiguous state: one order, unpaid, recoverable. A timeout has one order, an unknown payment outcome, and a customer who is likely to try again.

What this triage does not do is find the money. If the gateway charged and the request died, the reconciliation has to come from the gateway's own ledger, matched by amount and timestamp, because the order on your side carries no transaction ID. Treat the pending order plus the _debug_log_source meta as the anchor, and match from there.

What to do on Monday

Give the shared gateway settings a real timeout and a real failure path, and log a distinctive message when the gateway returns a failure result rather than throwing, because none of the build errors above write anything the default order step log will show you. Then run the _debug_log_source query weekly and clear it by hand: either the customer pays, or the order is cancelled with a note that explains why. A pending order nobody looks at is a support ticket that has not been filed yet.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Covers the WooCommerce order lifecycle: checkout, HPOS, session handling, transactional email and the post-purchase layer where most storefront complaints actually originate.

Continue

Related in WooCommerce & Storefront