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
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.
| Transition | Core reacts | Core 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
- Move the registration to
init, then grep the codebase foris_admin()wrapped around it. That grep finds most of the bugs of this class. - Add the label to
wc_order_statusesand 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. - 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 throughprocessingand into the new status to prove it does not reduce twice. - 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.
- 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