Skip to content
ThemesIonic — home
WooCommerce

Reacting to Any Order Transition with woocommerce_order_status_changed

The hook passes the order ID, the old status, the new status and the order, after the order is saved. It skips orders created as pending, reports pending for orders created in any other status, and the old status it reports is not always the one the order really had.

5 min read intermediate

woocommerce_order_status_changed fires after an order is saved with a different status, and passes four arguments: the order ID, the old status, the new status and the order object. It runs from WC_Order::save(), so whatever changed the status, whether the admin dropdown, a gateway, a REST call or update_status() in someone's code, the callback sees the order after it was written. That makes it the right hook when a transition's starting point matters. It also has blind spots that catch most first implementations.

The working shape

add_action( 'woocommerce_order_status_changed', 'ti_on_status_change', 10, 4 );

function ti_on_status_change( $order_id, $from, $to, $order ) {
	if ( 'cancelled' !== $to || ! in_array( $from, array( 'processing', 'on-hold' ), true ) ) {
		return;
	}

	if ( $order->get_meta( '_ti_cancel_synced' ) ) {
		return; // Already handled once.
	}

	// Tell the warehouse, then remember that we did.
	$order->update_meta_data( '_ti_cancel_synced', time() );
	$order->save_meta_data();
}

Register it with accepted_args set to 4. With the default of 1 you get only the ID, and code that reloads the order to read its status sees the new one with no way to know the old.

Saving meta with save_meta_data() instead of save() keeps the callback from writing the whole order again in the middle of its own save.

The order hooks fire in

When a transition happens, WC_Order::status_transition() runs these in sequence:

  1. woocommerce_order_status_{to}, for example woocommerce_order_status_cancelled, with the ID, the order and the transition array.
  2. The order note "Order status changed from X to Y."
  3. woocommerce_order_status_{from}_to_{to}, with the ID and the order.
  4. woocommerce_order_status_changed, with the four arguments.
  5. woocommerce_order_payment_status_changed, only when the order moves from a status that awaits payment (pending or failed by default) to a paid status.

Steps 3 to 5 run only when there is a previous status. Where completion itself is the event, the narrower hook is covered in woocommerce_order_status_completed. Payment has its own hook, explained in woocommerce_payment_complete.

The trap: the status an order is born with

A new order has no stored status, so when code sets one, WooCommerce takes the default status, pending (filterable with woocommerce_default_order_status), as the old one. What happens next depends on the first status:

  • Orders created as pending, which is what classic checkout and wc_create_order() without a status do, are not a change. Neither this hook nor woocommerce_order_status_pending fires. Use woocommerce_new_order for creation.
  • Orders created by code in another status, such as wc_create_order( array( 'status' => 'processing' ) ), do fire it on the first save, with $from = 'pending', although the order never sat in pending.
  • Orders created in the admin start as an auto-draft. When the admin saves them, WooCommerce treats the auto-draft as pending for the purpose of hooks, so $from is pending here too.
  • Block checkout orders are created as checkout-draft and moved to pending when the shopper places the order. The hook fires for that move with $from = 'checkout-draft', and then again for pending to processing when the gateway confirms payment. A callback that reacts to "anything to pending" runs for every block checkout order.

Other surprises in $from

  • Restoring from the trash. With HPOS, untrashing an order sets its status back to what it was and saves it, so the hook fires with $from = 'trash'. WooCommerce suppresses its own customer emails during the restore, but the status hooks still fire. A callback that sends its own message on any change to processing sends it again to a customer whose order was trashed and restored by mistake.
  • Several status changes before one save. If code calls set_status() twice and then save() once, the hook fires once, with the first old status and the last new status. The middle step never appears.
  • Unknown statuses. If the old status is not a registered order status, for example after a plugin that registered a custom status was deactivated, it is reported as pending.

Exceptions do not stop anything

The whole transition block is wrapped in a try/catch. If your callback throws, WooCommerce logs the error and adds an order note starting "Error during status transition." The status change itself is already saved, and hooks that come after yours in the sequence are skipped for that save.

So the hook cannot veto a status change. Throwing to "block" a cancellation leaves the order cancelled, with an error note, and skips other plugins' listeners. To prevent a transition, stop the action that requests it, such as the admin form or the REST request, before the status is set.

When a narrower hook is better

Use woocommerce_order_status_{from}_to_{to} when exactly one transition matters and custom statuses are unlikely. Use woocommerce_order_status_{to} when you need the manual flag. Use this hook when the logic depends on both ends of the transition, or when one callback should see every change, for example to sync status to an external system.

Keep the callback light

Core attaches several listeners of its own: order count caches, analytics imports, review reminders and push notifications. Bulk actions in the orders list save each order in turn, so a callback that calls a remote API inline multiplies the time of a 100-order bulk edit by 100 calls. Queue the slow work with Action Scheduler (as_enqueue_async_action()) and make the queued job idempotent, as the meta flag above does.

Verify

  1. Change a test order from processing to cancelled in the admin and confirm the callback runs once with the expected $from and $to.
  2. Create an order in the admin directly as processing and check that $from is pending.
  3. Place a block checkout order and log every call. Expect checkout-draft → pending, then pending → processing.
  4. With HPOS, trash and restore an order, and confirm your callback ignores $from = 'trash' if it should.
  5. Look at the order notes for "Error during status transition" after each test.

Frequently asked

No. $from and $to are plain slugs such as pending, processing or completed. The wc- prefix exists only in how the status is stored and in the keys of wc_get_order_statuses(), so compare against unprefixed strings.
Not from this hook. The manual flag is recorded with the transition, but only the woocommerce_order_status_{to} hooks receive it, as the third argument. woocommerce_order_edit_status also fires for changes flagged as manual, which is how the order edit screen saves them.
Yes. The hook fires from WC_Order::save() after the data store has written the order, whichever storage the store uses. It does not depend on post status transitions, which is why save_post or transition_post_status code stops working under HPOS and this hook does not.

Related guides