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

Custom WooCommerce Order Statuses That Survive REST and WP-CLI

A custom order status needs three registrations, not one. Get the hook timing wrong and orders quietly fall back to pending payment.

Ana Ferreiro · @aferreiro

WooCommerce engineering

Topic
WOO
Section
WooCommerce & Storefront
Published
19 FEB 2026
Reading time
7 MIN
Words
1849W
Technical cover graphic for Custom WooCommerce Order Statuses That Survive REST and WP-CLI
Generated schematic — WOO series, REV 2.6.

The order was in "Awaiting courier" when the warehouse closed. By morning it was back in "Pending payment" and the customer had received a second confirmation email. Nothing in the logs explained it, because nothing had failed. WooCommerce had refused the status and written a different one in its place.

Here is the line that does it. WC_Order::set_status() compares the new value against array_keys( wc_get_order_statuses() ) and, when the comparison fails, substitutes pending. No exception, no _doing_it_wrong(), no notice on the edit screen. A status that is missing from that array is not an error. It is a different status.

Getting a status to stick means keeping three structures in agreement, and only one of them takes the filter that most tutorials name. The timing is the part that bites later: register in the wrong context and the status works on the screen where somebody clicks it while disappearing from REST, WP-CLI and the front end.

Three registries describe an order status, and they are not synchronised

The post status. WooCommerce registers its own seven statuses on init at priority 9. WC_Post_Types::register_post_status() loops over the array returned by the woocommerce_register_shop_order_post_statuses filter and calls WordPress's register_post_status() once per entry, with names such as wc-processing. That gives the name a label, a place in the WordPress status list, and a slot in wp_count_posts() on the legacy orders store.

The label map. wc_get_order_statuses() builds a separate wc- prefixed key to label map and passes it through the wc_order_statuses filter. This is the map that matters. The order edit dropdown reads it, the orders list table reads it, the REST API derives its status enum from it, set_status() validates against it, and the HPOS query builder intersects requested statuses with it.

The paid list. wc_get_is_paid_statuses() returns unprefixed slugs, processing and completed, through woocommerce_order_is_paid_statuses. It backs WC_Order::is_paid() and the customer lifetime spend queries.

The prefix is also the source of a smaller trap. set_status() strips wc- before it validates, so it accepts either spelling. get_status() and has_status() do not: they compare against the unprefixed value, so $order->has_status( 'awaiting-courier' ) returns true while $order->has_status( 'wc-awaiting-courier' ) returns false. Two spellings, one of them quietly wrong inside a conditional.

A filter that does not exist

There is no woocommerce_order_statuses filter in WooCommerce core. The name looks plausible next to woocommerce_order_status_{status}, and it is a common first guess, but the callback never runs and nothing warns you. The map you want is wc_order_statuses.

Register on init, because the admin is not the only caller

If the registration sits inside is_admin(), or on admin_init, the status exists exactly where somebody can click it and nowhere else. A REST call that filters by it returns a validation error. wp wc order list --status=awaiting-courier fails the same way. Move the same code to init and every context has it, because init runs on front-end requests, admin requests, REST requests and CLI commands alike.

A complete registration for one status, in the three pieces it needs:

// 1. Teach WordPress and the legacy post store the name.
add_action( 'init', function () {
    register_post_status(
        'wc-awaiting-courier',
        array(
            'label'                     => __( 'Awaiting courier', 'store-statuses' ),
            'public'                    => false,
            'exclude_from_search'       => false,
            'show_in_admin_all_list'    => true,
            'show_in_admin_status_list' => true,
            'label_count'               => _n_noop(
                'Awaiting courier <span class="count">(%s)</span>',
                'Awaiting courier <span class="count">(%s)</span>',
                'store-statuses'
            ),
        )
    );
} );

// 2. Add the label to the map everything else reads.
add_filter( 'wc_order_statuses', function ( array $statuses ) {
    $ordered = array();

    foreach ( $statuses as $key => $label ) {
        $ordered[ $key ] = $label;

        if ( 'wc-processing' === $key ) {
            $ordered['wc-awaiting-courier'] = __( 'Awaiting courier', 'store-statuses' );
        }
    }

    return $ordered;
} );

// 3. Only if the money has already arrived.
add_filter( 'woocommerce_order_is_paid_statuses', function ( array $statuses ) {
    $statuses[] = 'awaiting-courier';

    return $statuses;
} );

Two details in that snippet carry more weight than they look. The loop in step 2 reinserts the status after processing rather than appending it, because the dropdown renders in array order and a status listed after refunded reads like an afterthought to whoever is packing boxes. And the slug has a budget. wp_posts.post_status and wc_orders.status are both varchar(20), and the wc- prefix spends three of those characters. Seventeen is the ceiling, and the column will not hold more.

The two visibility flags deserve a decision rather than a copy and paste. show_in_admin_status_list controls the WordPress status list. show_in_admin_all_list controls whether orders in the status are counted in the "All" view of the orders screen. Set the second one to false for a status that means parked or abandoned work, and those orders stop inflating the All tab while staying reachable from their own tab. Merchants notice this distinction faster than developers do, usually as a complaint that the totals on two screens disagree.

There is a second route to the same place. Filter woocommerce_register_shop_order_post_statuses and WooCommerce will call register_post_status() for you inside its own loop, using argument shapes identical to core's. Both approaches work. Both break in the same way if you move them off init.

A status is a label, and core only reacts to the seven it knows

The action hooks are named after the destination status, without the wc- prefix. woocommerce_order_status_awaiting-courier fires on arrival, and woocommerce_order_status_processing_to_awaiting-courier fires only on that one move. Argument lists are worth checking against your own version rather than mine: the code I read passes the order ID, the order object and the transition array to the plain hook, and the order ID and the order object to the from-to variant.

TransitionCore reactsCore stays quiet
processing → awaiting-courier Your action fires. Stock stays reduced from the earlier move. No email, no product sales counter change.
pending → awaiting-courier Your action fires. Stock untouched. The customer hears nothing.
awaiting-courier → completed wc_maybe_reduce_stock_levels(), wc_update_total_sales_counts() and the completed email all run. Nothing. This is a built-in destination.
awaiting-courier → cancelled wc_maybe_increase_stock_levels() runs when stock had been reduced. Product sales counters, which core decrements only on the cancelled transitions it was told about.
Any transition into the status Analytics stores a wp_wc_order_stats row with whatever status the order holds, so the order keeps counting towards revenue. The legacy dashboard totals, which read woocommerce_reports_order_statuses and do not know the status exists.

Stock is the part people get wrong, and the reason is a hardcoded list. Core attaches wc_maybe_reduce_stock_levels() to woocommerce_order_status_completed, woocommerce_order_status_processing, woocommerce_order_status_on-hold and woocommerce_payment_complete. A new status is in none of those, so an order sitting in it holds no stock. If the status means "packed and gone", reduce it yourself:

add_action( 'woocommerce_order_status_awaiting-courier', function ( $order_id ) {
    // Returns early when this order already reduced stock.
    wc_maybe_reduce_stock_levels( $order_id );
}, 10, 1 );

That call is safe to repeat. It reads the flag core keeps for this purpose, _order_stock_reduced post meta on the legacy store and the order_stock_reduced column in wp_wc_orders_operational under HPOS, and returns early when the flag is set. An order that passes through two stock-reducing statuses still reduces once.

Counts and tabs come from the label map, not from the post status registry. wc_orders_count() reaches OrderUtil::get_count_for_type(), which runs SELECT status, COUNT(*) FROM wp_wc_orders WHERE type = %s GROUP BY status on HPOS or calls wp_count_posts( 'shop_order' ) on the legacy store, then merges the result over the keys of wc_get_order_statuses() so that every known status has a number. A status missing from that map has no key to merge into. Its count never appears, and the tab that would show it is never drawn, even with the orders sitting in the table.

Emails follow the same pattern. Core wires its order emails to a fixed list of transitions: woocommerce_order_status_pending_to_processing, woocommerce_order_status_pending_to_on-hold, woocommerce_order_status_completed, woocommerce_order_status_failed, and a dozen more in the same shape. None of them name your status, so nothing is sent. If the customer should hear about it, hook the mailer yourself or move the order into a status that already owns an email.

The paid list is not the reporting list

This is where the usual advice goes wrong. Adding your status to woocommerce_order_is_paid_statuses makes WC_Order::is_paid() return true and folds the order into customer lifetime spend. It does not move a single figure in a revenue report, because no revenue report consults that list.

There are two report systems and two different lists. The older report classes, still in place for the dashboard widgets and the legacy report screens, filter on woocommerce_reports_order_statuses, which defaults to completed, processing and on-hold. Analytics works the other way round and declares what to leave out: it reads the woocommerce_excluded_report_order_statuses option and filters that through woocommerce_analytics_excluded_order_statuses. On the version I checked, the default excluded set is pending, cancelled and failed. A brand new status appears in neither default list. The legacy screens ignore it. Analytics counts it as revenue from the day it exists, whether or not anyone paid for those orders.

The asymmetry does not announce itself, and that is the problem with it. If the status is a pause rather than a sale, add it to the Analytics excluded list in the report settings. If it is a sale, leave Analytics alone and decide deliberately whether is_paid() should be true, because lifetime spend feeds customer segmentation and the numbers get harder to correct with every month of history you build on top of them.

Where this advice stops working

The exclusion list is a merchant-editable setting, which is both the fix and the problem. Somebody with access to the Analytics settings screen can change it on a Tuesday afternoon, and every historical figure silently moves with it. Two people looking at the same month can see different totals without either of them being wrong about what they configured.

Scope is the other limit. A status is cheap; a workflow is not. Once the status needs its own email, its own stock rule and its own reporting treatment, you are building a state machine on top of post_status, and the behaviour you forget to write down is the one that will page somebody. At that point a custom order status is no longer a filter. It is an order lifecycle, and it deserves a written specification and a test that walks an order from pending to closed.

What to do on Monday

  1. Move the registration to init, then grep the codebase for is_admin() wrapped around it. That grep finds most of the bugs of this class.
  2. Add the label to wc_order_statuses and confirm from the CLI: wp eval 'var_dump( wc_is_order_status( "wc-awaiting-courier" ) );'. False means the filter is not running in a CLI context, and REST will not see the status either.
  3. Decide the stock rule. If the status is a fulfilment status, hook wc_maybe_reduce_stock_levels() to the status action, then push a test order through processing and into the new status to prove it does not reduce twice.
  4. Decide the reporting treatment in Analytics settings before the first real order lands in the status, not after the first month's figures are published.
  5. Place one test order, move it through the status, and check the tab count with the object cache flushed. A count that appears only after a manual flush is the merge behaviour above.

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