WooCommerce Custom Order Status Emails Not Sending After HPOS

WooCommerce custom order status emails silently stopped after HPOS migration. Here is why the transition hook misses and the exact fix that restores notifications.
WooCommerce Custom Order Status Emails Not Sending After HPOS
WooCommerceHPOSWordPress
June 9, 20265 min read944 words

The Problem

A client ran the HPOS migration on a store with four custom order statuses (wc-awaiting-stock, wc-partial-shipped, wc-quality-check, wc-ready-to-ship). The migration finished clean. Orders moved through statuses normally in the admin. Reports stayed accurate. The only thing that broke was email: every custom-status transition stopped sending its notification, while the built-in processing and completed emails kept working fine.

No PHP error, no fatal in wc-logs/, no failed job in Action Scheduler. The WC_Email_Customer_* class for each custom status still loaded. WC()->mailer()->emails listed it. Triggering it manually from the order edit screen sent the email correctly. It was only the automatic transition that did nothing.

The clue came from a dump of the action queue. Before HPOS, the email class hooked into woocommerce_order_status_awaiting-stock_to_partial-shipped. After HPOS, that hook still fired — but the email class was no longer subscribed to it. Somewhere in the migration the registration silently dropped for every transition that involved a custom status on both sides.

Why It Happens

WC_Email subclasses traditionally register their triggers in the constructor by reading $this->id and attaching to a list of status-change hooks. The default WC_Email_Customer_Processing_Order::__construct() calls add_action('woocommerce_order_status_pending_to_processing', [$this, 'trigger']) and similar. Custom emails copy this pattern with their own status pairs.

Pre-HPOS, the order status came back from get_post_status() and the hook name was built from the raw wc- prefixed slugs that WooCommerce silently stripped. Custom statuses registered through register_post_status() matched cleanly because the storage and the hook used the same naming.

HPOS changed the source of truth. Statuses now live in the wp_wc_orders.status column, not in wp_posts.post_status. WooCommerce 10.7 and later normalises the slug at write time: anything that does not match a registered status in wc_get_order_statuses() gets stored with the wc- prefix intact instead of stripped, and the transition hook fires with that prefixed name. So your custom email is listening for woocommerce_order_status_awaiting-stock_to_partial-shipped, but the actual hook firing post-migration is woocommerce_order_status_wc-awaiting-stock_to_wc-partial-shipped. Subscriber and event never meet.

The HPOS developer guide mentions the status normalisation in passing but does not flag that legacy email registrations break against it. If you registered custom statuses with register_post_status() only — without also calling add_filter('wc_order_statuses', ...) to add them to the canonical list — WooCommerce never learns the unprefixed form exists, and the hook name diverges from what your email subscribed to.

The Fix

Two changes. First, make sure every custom status is registered in wc_order_statuses so the slug normalises correctly. Second, subscribe the email to both the prefixed and unprefixed transition hooks so you survive the next core change without breaking again.

Step 1: Register the status with WooCommerce, not just WordPress.

add_action('init', function () {
    register_post_status('wc-awaiting-stock', [
        'label'                     => 'Awaiting Stock',
        'public'                    => false,
        'show_in_admin_status_list' => true,
        'label_count'               => _n_noop(
            'Awaiting Stock <span class="count">(%s)</span>',
            'Awaiting Stock <span class="count">(%s)</span>'
        ),
    ]);
});

add_filter('wc_order_statuses', function ($statuses) {
    $statuses['wc-awaiting-stock']   = 'Awaiting Stock';
    $statuses['wc-partial-shipped']  = 'Partial Shipped';
    $statuses['wc-quality-check']    = 'Quality Check';
    $statuses['wc-ready-to-ship']    = 'Ready to Ship';
    return $statuses;
});

The wc_order_statuses filter is what HPOS reads when it decides whether to strip the prefix in the transition hook name. Skip this and you stay on the broken path forever.

Step 2: Register the email trigger against both hook variants.

class WC_Email_Awaiting_Stock_To_Partial_Shipped extends WC_Email {
    public function __construct() {
        $this->id             = 'awaiting_stock_to_partial_shipped';
        $this->title          = 'Awaiting Stock to Partial Shipped';
        $this->customer_email = true;
        $this->template_html  = 'emails/customer-partial-shipped.php';
        $this->template_plain = 'emails/plain/customer-partial-shipped.php';

        // Subscribe to both naming variants. The prefixed one fires
        // post-HPOS migration; the unprefixed one fires once the
        // status is registered in wc_order_statuses.
        add_action(
            'woocommerce_order_status_awaiting-stock_to_partial-shipped_notification',
            [$this, 'trigger'],
            10,
            2
        );
        add_action(
            'woocommerce_order_status_wc-awaiting-stock_to_wc-partial-shipped_notification',
            [$this, 'trigger'],
            10,
            2
        );

        parent::__construct();
    }

    public function trigger($order_id, $order = false) {
        if (!$order_id) return;
        if (!is_a($order, 'WC_Order')) {
            $order = wc_get_order($order_id);
        }
        if (!$order || !$this->is_enabled() || !$this->get_recipient()) return;

        $this->object    = $order;
        $this->recipient = $order->get_billing_email();
        $this->send($this->recipient, $this->get_subject(), $this->get_content(), $this->get_headers(), $this->get_attachments());
    }
}

Note the _notification suffix. WooCommerce 10.x fires the transition hook twice — once without the suffix and once with — but only the _notification variant carries the second argument with the WC_Order object. Subscribing to the suffixed version means your trigger() does not have to refetch the order, which matters once you have a few thousand orders moving per day.

Step 3: Verify with a real transition.

$order = wc_get_order(12345);
$order->update_status('partial-shipped', 'Manual test transition.');

// Then check the queue. If Action Scheduler is processing emails async:
WC()->queue()->get_next('woocommerce_send_email', null, 'email');

Tail wc-logs/fatal-errors-*.log and wp-content/uploads/wc-logs/email-*.log while you do this. If the email registered correctly, you see the dispatch line within a second.

The Lesson

HPOS changed when WooCommerce normalises status slugs, and any custom email that registered its trigger against the unprefixed hook name silently disconnects after migration. Add your custom statuses to wc_order_statuses so the prefix gets stripped, and subscribe to the _notification variant of the transition hook so you get the order object passed in. The fix is small, but it only works if both halves are in place.

If your WooCommerce HPOS migration has emails or webhooks silently broken, that is the work I do. See my services. For another HPOS-after-migration trap, read WooCommerce HPOS bulk status update fails.

Stuck on a WooCommerce HPOS migration? Get it shipped.

Back to blogStart a project