Running Code When a WooCommerce Order Is Completed
The hook fires on every route into the completed status — admin dropdown, bulk action, REST call, automatic completion of virtual orders, and an order moved back to completed after a refund. A callback that assumes a single, first-time event is wrong on four of those.
The hook fires on every transition into completed, from anywhere, as many times as it happens. woocommerce_order_status_completed is one of a family of hooks that WC_Order fires when its status changes, and it does not distinguish an administrator choosing a dropdown value from a bulk action, a REST request, a cron job, or an order being re-completed after a partial refund. Reliable code on this hook is code that checks the order before acting and leaves a mark when it has.
The routes into completed
- Manual change on the edit order screen, or the order list's quick actions.
- Bulk action "Change status to completed" — one hook call per order, in one request.
- Automatic completion when every item is virtual and downloadable, triggered by
payment_complete(). - REST API
PUTwithstatus: completed, which is what most fulfilment integrations send. - Re-completion — an order fully refunded or put on hold and then moved to completed again fires the hook a second time. A partial refund leaves the status alone and does not fire it.
- Import or migration tools that set statuses in bulk.
The last two are the ones that make a naive callback send a second "your order has shipped" message or provision a licence twice.
The working shape
add_action( 'woocommerce_order_status_completed', 'ti_on_completed', 10, 3 );
function ti_on_completed( $order_id, $order, $transition ) {
if ( ! $order instanceof WC_Order ) {
$order = wc_get_order( $order_id );
}
if ( ! $order || $order->get_meta( '_ti_completed_handled' ) ) {
return;
}
// Do the work, or better: schedule it.
as_enqueue_async_action( 'ti_handle_completed_order', array( 'order_id' => $order_id ) );
$order->update_meta_data( '_ti_completed_handled', current_time( 'mysql', true ) );
$order->save();
}
The second argument is the order object as of the transition, so there is no need to reload it — older snippets register the hook with one argument and call wc_get_order(), which also works. The third is the transition itself: an array with from, to, note and manual, where manual is true when a person changed the status in the admin. That answers both "where did it come from" and "who did it" without another hook. The meta flag is the idempotency guard; update_meta_data() and save() are the storage-agnostic calls that work whether or not high-performance order storage is on. Writing with update_post_meta() on an HPOS store puts the flag where the order will never see it.
Scheduling instead of running inline matters on this hook more than most. A bulk completion of fifty orders runs the callback fifty times in one request; an API call to a shipping provider in each of them turns a one-second action into a timed-out page, and the status change may already be committed for some orders and not others.
When the generic hook is better
woocommerce_order_status_changed fires for every transition and passes four values: the order ID, the old status, the new status and the order. It is the right choice when the previous status matters:
add_action( 'woocommerce_order_status_changed', 'ti_track_transition', 10, 4 );
function ti_track_transition( $order_id, $from, $to, $order ) {
if ( 'completed' === $to && 'processing' === $from ) {
// Fulfilled in the normal path, not re-completed after a refund.
}
}
The even more specific woocommerce_order_status_{from}_to_{to} hooks exist as well — woocommerce_order_status_processing_to_completed — and are convenient until a store adds a custom status and the transition you wrote for stops being the one that happens.
What "completed" means on your store
For a store shipping physical goods, completed is a human decision made after dispatch, and hooking it is hooking that decision. For a store selling downloads, completed is automatic at payment, which makes this hook equivalent to woocommerce_payment_complete for those orders and a poor place for anything that should wait for a person. Mixed stores get both behaviours and need the callback to inspect the items — $order->get_items() and each product's is_virtual() — before deciding what completed meant this time.
Stores that complete orders automatically through a snippet on woocommerce_payment_complete_order_status should know that every such order fires this hook at payment time, with all the mail that implies; the usual consequence is a customer asking why their order was marked complete before anything was sent, a question that lands in the same inbox as emails not sending.
Context the callback does not have
The transition may run in a request with no logged-in user, no session and no cart: a webhook, a cron run, a WP-CLI command. Anything that reads the current user to decide who completed the order gets nobody. The transition array's manual flag tells you whether a person did it; the order note WooCommerce adds names the user when there is one.
Verify
Complete an order three ways — from the dropdown, by bulk action, and through the REST API — and confirm the callback ran once for each and the meta flag was written. Then move it to on-hold and complete it again: the flag should stop the callback the second time. If instead your work ran twice, the flag is being written to the wrong storage, and the check in the database update not working for HPOS sync explains where it went.
Frequently asked
- Yes, once per order, inside the same request. Fifty orders completed in bulk run your callback fifty times before the page returns, which is why anything slower than a database write should be queued rather than run inline.
- Orders containing only virtual, downloadable products are completed automatically the moment payment clears. The transition is real and the hook fires; the person who 'touched' it was the payment gateway.
- From the third argument. The hook passes the order ID, the order, and a transition array with from, to, note and manual keys; register the callback with three accepted arguments to receive it. woocommerce_order_status_changed passes the old and new status as separate arguments if you prefer that shape.