What woocommerce_new_order Actually Gives You
The hook fires inside the data store, before the order's line items are written, for every order that is created outside a draft status. Reloading the order by ID inside it returns an order with no items.
woocommerce_new_order fires once when an order first exists in the database with a non-draft status — which is earlier than most callbacks assume. It passes two arguments, the order ID and the WC_Order object, and it is fired by the order data store itself, both the posts-based store and the HPOS orders table. That location explains its two surprises: the order is not paid yet, and its line items are not saved yet.
The working shape
add_action( 'woocommerce_new_order', 'ti_log_new_order', 10, 2 );
function ti_log_new_order( $order_id, $order ) {
// Use the object you were given; do not reload it by ID here.
$lines = array();
foreach ( $order->get_items() as $item ) {
$lines[] = $item->get_name() . ' × ' . $item->get_quantity();
}
wc_get_logger()->info(
sprintf( 'Order %d created via %s: %s', $order_id, $order->get_created_via(), implode( ', ', $lines ) ),
array( 'source' => 'ti-orders' )
);
}
Register with accepted_args of 2. With the default of 1 you only get the ID, and the natural next line — wc_get_order( $order_id ) — is exactly the call that misleads you.
The trap: the order has no saved items yet
WC_Order::save() does two things in sequence: it asks the data store to create the order row, then calls save_items() to write the line items. woocommerce_new_order is fired from inside the first step, at the end of the data store's create() method. At that moment:
- the order row exists and has an ID;
- the items you see on the passed
$orderobject are in memory but not in the database, and have no item IDs; wc_get_order( $order_id )reads the database and returns an order whoseget_items()is empty, and whose totals are whatever was set before saving.
So a callback that receives only the ID and reloads the order sends an empty payload to the ERP, the Slack channel or the warehouse. The fix is either to use the object from the second argument, as above, or to move the work to a hook that runs after the save completes.
Writing to the order from here is also fragile. Calling $order->save() inside the callback saves the order a second time while the first save is still in progress, before its items are written. Add meta in woocommerce_checkout_create_order or woocommerce_checkout_update_order_meta instead, where the checkout expects it.
When it fires in each checkout
The data store skips the hook for orders created as auto-draft, draft or checkout-draft, and fires it later when such an order is updated from a draft status to a real one. That makes the timing differ by route:
| Route | When woocommerce_new_order fires |
|---|---|
| Classic checkout | When the order is created on submit, normally as pending payment, before the gateway redirect |
| Block checkout | Not when the draft order is created; when the Store API moves it out of checkout-draft on place order |
| Admin › Add order | Not on the auto-draft; on the first save with a real status |
REST API, wc_create_order() |
Immediately, unless the status is a draft one |
In the block and admin cases the hook fires on the update path, after the items were saved earlier, so the empty-items trap mostly bites the classic checkout, the REST API and code that creates orders programmatically in one step. Because a single store usually has more than one of these routes, test your callback against each of them rather than only the checkout.
The update path has its own condition: the hook fires only when the status actually changes from a draft status to a non-draft one. Re-saving a pending order does not fire it again, and neither does a failed payment retry that reuses the same pending order.
It is not "order paid"
Pending payment means the shopper has submitted the form and may still abandon the gateway. Sending a fulfilment request, reducing a licence count or emailing a download link from here sends them for orders that are never paid.
Choose the hook that matches the business event:
- Paid, online gateway: woocommerce_payment_complete, fired when a gateway confirms payment.
- Ready to ship, including manual payment methods: the
processingstatus transition. - Fulfilled: woocommerce_order_status_completed.
- Shopper has returned to the site: woocommerce_thankyou, which is page-load based and unreliable for anything important.
woocommerce_new_order is right for things that genuinely concern every order, paid or not: logging, analytics on checkout attempts, fraud scoring, or reserving something that a later failure will release.
Duplicates, retries and imports
The hook fires once per created order, not once per purchase. A shopper who pays, fails, and checks out again with a modified cart may create a second order; subscription renewals and order imports each create orders and fire it too. Use $order->get_created_via() — checkout, store-api, admin, rest-api and so on — to restrict the callback when it should only react to storefront orders, and make external calls idempotent by sending the order ID as the reference.
Anything slow belongs off the request: the shopper is waiting for the payment redirect while your callback runs. Queue the work with Action Scheduler (as_enqueue_async_action(), bundled with WooCommerce) and do the HTTP call there, where a timeout does not cost a sale.
Common mistakes
- Reloading the order by ID and reading items or totals.
- Treating the hook as a payment confirmation.
- Calling
$order->save()inside the callback. - Making a blocking HTTP request to a third-party service in the checkout request.
- Testing only the classic checkout when the store uses the block checkout, which fires the hook at a different moment.
Verify
- Place a classic checkout order and check the log line lists every item.
- Repeat in the block checkout and confirm exactly one log line per order.
- Create an order in the admin and check the line appears on first save, not on opening the screen.
- Abandon a payment at the gateway and confirm nothing that should require payment has happened.
Frequently asked
- Because the hook fires inside the data store's create step, and line items are saved after it returns. If you reload the order with wc_get_order() you read the database, where no items exist yet. Use the order object passed as the second argument, or move the code to a later hook.
- No. A classic checkout order is normally created as pending payment, before the shopper is sent to the payment gateway. Use woocommerce_payment_complete or a status hook such as processing for anything that should only happen after payment.
- Yes, for any order created through the data store. Admin orders start as auto-draft and the hook fires when they are first saved with a real status; REST and wc_create_order() orders fire it immediately unless created as a draft.