TL;DR: woocommerce_checkout_order_created fires once the order row exists — and only on the shortcode checkout.
- Signature:
do_action( 'woocommerce_checkout_order_created', $order )— one parameter, aWC_Order. Added in WooCommerce 4.3.0. - It runs inside
WC_Checkout::create_order(), immediately after$order->save()and after the order meta hook. - The blocks checkout never reaches that method, so it fires
woocommerce_store_api_checkout_order_createdinstead. - It also misses admin-created, API-created and subscription renewal orders.
- If you need every order, bind to a status transition, not to a checkout hook.
/ Overview
What is woocommerce_checkout_order_created?
It is the action WooCommerce fires the moment a checkout order has been written to the database. It lives at the end of WC_Checkout::create_order() in class-wc-checkout.php, carries an @since 4.3.0 tag, and is the earliest point in the checkout at which you are holding a saved order with a real ID.
That last part is what makes it useful. Its neighbour woocommerce_checkout_create_order runs a few lines earlier and gives you the order before it is saved, which is the right place to change it and the wrong place to tell anything about it — the order has no ID yet, and an exception thrown later can still discard it entirely.
/ Signature
What is the exact signature?
One parameter. That is the whole surface:
PHP — class-wc-checkout.php
/** * Action hook fired after an order is created. * * @since 4.3.0 */ do_action( 'woocommerce_checkout_order_created', $order );
| Parameter | Type | What it holds |
|---|---|---|
$order | WC_Order | The saved order object, with a real ID, line items, totals and addresses already persisted. |
Passing the object rather than an ID is a small convenience with a real consequence: you can read totals and items without a wc_get_order() round trip, and you are guaranteed to be looking at the same instance the checkout just saved rather than a re-read that might miss something still in memory.
/ Sequence
Where does it sit in the checkout hook sequence?
Fourth, in a run of hooks that all fire within a few lines of each other. The order is fixed and worth knowing, because picking the wrong neighbour is the most common reason a callback misbehaves:
| # | Hook | Parameters | Order saved? |
|---|---|---|---|
| 1 | woocommerce_checkout_create_order | $order, $data | No — modify it here |
| 2 | $order->save() | — | The write itself |
| 3 | woocommerce_checkout_update_order_meta | $order_id, $data | Yes |
| 4 | woocommerce_checkout_order_created | $order | Yes |
| 5 | woocommerce_checkout_order_processed | $order_id, $posted_data, $order | Yes — later in the request |
Between 2 and 4 there is a guard worth knowing about. Recent WooCommerce re-reads the persisted order and throws if the cart had items but the saved order has none, so a save that silently dropped every line item aborts the checkout rather than completing a paid-but-empty order. By the time hook 4 runs, that check has already passed.
/ Neighbours
What do woocommerce_checkout_create_order and _line_item pass?
The order before it is saved, and each line item before it is attached. These are the two hooks people land on this page looking for, so here they are exactly as WooCommerce 10.9 declares them in class-wc-checkout.php:
PHP — class-wc-checkout.php, both @since 3.0.0
/** * Action hook to adjust order before save. * * @since 3.0.0 */ do_action( 'woocommerce_checkout_create_order', $order, $data ); /** * Action hook to adjust item before save. * * @since 3.0.0 */ do_action( 'woocommerce_checkout_create_order_line_item', $item, $cart_item_key, $values, $order );
| Hook | Parameter | Type | What it holds |
|---|---|---|---|
woocommerce_checkout_create_order | $order | WC_Order | The unsaved order. Addresses, payment method and cart data are set; there is no ID yet. |
$data | array | The posted checkout fields, already validated and sanitised: billing_*, shipping_*, payment_method, order_comments. | |
woocommerce_checkout_create_order_line_item | $item | WC_Order_Item_Product | The line item, with product, quantity, subtotal and total set, not yet added to the order. |
$cart_item_key | string | The cart hash for this item — the key into WC()->cart->get_cart(). | |
$values | array | The raw cart item array: product_id, variation_id, quantity, line totals, and any custom cart data a plugin added. | |
$order | WC_Order | The same unsaved order as above. |
Both are modify hooks, not notify hooks. woocommerce_checkout_create_order is where you set a custom meta value or override a field before the single save that follows; _line_item is where cart-level custom data, a gift message or an engraving option, gets copied onto the item so it survives into the order. The $values array is the only place that custom cart data still exists at this point, which is why the line-item hook is the standard way to persist it.
Neither is a safe place to tell another system anything. The order has no ID, the line items are not attached, and an exception anywhere in the rest of create_order() discards all of it. Fire your notification from the hook this page is about, four steps later, once the write has happened. WooCommerce also fires the sibling hooks _fee_item, _shipping_item, _tax_item and _coupon_item with the same shape, one per item type, all before the save.
/ Blocks
Why does it never fire on the blocks checkout?
Because the blocks checkout does not call WC_Checkout::create_order(). It posts to the Store API, which builds the order through its own route and fires its own parallel actions — woocommerce_store_api_checkout_order_created and woocommerce_store_api_checkout_order_processed. WooCommerce states the rule plainly in its hook alternatives documentation: hooks that fire in the shortcode process do not fire on Store API requests from the blocks.
This is the single biggest source of "my order hook stopped working" reports, and it usually arrives as a site change rather than a code change — someone swaps the shortcode checkout page for the Checkout block, and an integration that worked for two years goes quiet without a single error. Nothing throws. The callback is simply never called.
PHP — cover both checkout paths
$notify = function( $order ) { // One callback, both routes. my_enqueue_order_notification( $order->get_id() ); }; add_action( 'woocommerce_checkout_order_created', $notify ); add_action( 'woocommerce_store_api_checkout_order_created', $notify );
Any other WC hooks that fire in the Shortcode process will not fire on Store API requests from the blocks. — WooCommerce block development documentation
Seeing it run beats reading about it. The live preview boots a throwaway WordPress with Webhook Actions already installed and demo deliveries sitting in the log — no signup, nothing left on your machine afterwards.
/ Duplicates
Can the same order fire this hook twice?
Yes, and this one catches almost everybody. create_order() does not always create an order. It first looks for an order the session is already awaiting payment on, and if that order still matches the cart — same items, same total, verified through a cart hash — and is still pending or failed, it resumes that order instead of making a new one.
PHP — the resume branch in create_order()
$order_id = absint( WC()->session->get( 'order_awaiting_payment' ) ); $cart_hash = WC()->cart->get_cart_hash(); $order = $order_id ? wc_get_order( $order_id ) : null; if ( $order && $order->has_cart_hash( $cart_hash ) && $order->has_status( [ 'pending', 'failed' ] ) ) { do_action( 'woocommerce_resume_order', $order_id ); $order->remove_order_items(); // re-added below } else { $order = new WC_Order(); }
The hook at the end of the method does not know or care which branch ran. So a customer whose card is declined, who then presses pay again, produces two woocommerce_checkout_order_created calls carrying the same order ID — three attempts, three calls. If your callback posts to a CRM, that is three records for one order — and if it sends an order SMS, three billed messages.
The defence is a marker on the order rather than a check in your own code path, because the second call happens in a completely separate request:
PHP — fire once per order, not once per attempt
add_action( 'woocommerce_checkout_order_created', function( $order ) { if ( $order->get_meta( '_my_notified' ) ) { return; // resumed order, already sent } $order->update_meta_data( '_my_notified', time() ); $order->save(); my_enqueue_order_notification( $order->get_id() ); } );
Listening for woocommerce_resume_order is the other half of the picture if you want to distinguish a retry from a first attempt rather than just suppress it — it fires only on the resume branch, and only ever with the order ID.
/ Comparison
How does it differ from order_processed and new_order?
By timing, payload, and how much of the world they cover. All three describe "an order happened", and they disagree about when that is:
| Hook | Fires when | Covers blocks? | Covers admin orders? |
|---|---|---|---|
woocommerce_checkout_order_created | Order row written, inside create_order() | No | No |
woocommerce_checkout_order_processed | Later in the checkout request, with posted data | No | No |
woocommerce_new_order | Any order is first persisted, from any source | Yes | Yes |
woocommerce_order_status_processing | Order reaches the processing status | Yes | Yes |
So the choice follows from the question you are answering. Enriching a checkout order with metadata is a job for order_created, because you want the earliest saved state and you only care about checkout. Reacting to a paid order is a job for a status transition, because payment is what you actually care about and it happens on every path — including gateway callbacks that arrive minutes later, long after the checkout request has ended.
/ Delivery
What breaks if you send the request inline?
Checkout gets slower and less reliable, in that order. A blocking HTTP call in this hook runs inside the customer's checkout request, between the order being saved and the payment gateway being handed control. Every millisecond the endpoint takes is a millisecond the buyer spends on a spinner at the most abandonment-sensitive moment of the entire session.
Then there is what an exception does here. The hook fires inside a try block whose catch releases the order's coupons and discards the order. A callback that throws — a JSON encoding failure, a fatal in a third-party client library — does not just fail to notify: it can take down a checkout that had already succeeded. Wrapping your own callback in a try/catch is not defensive programming here, it is required.
The sound version is to record the intent and return. Write the order ID somewhere durable, let a background worker do the HTTP with retries and a log, and keep the checkout request doing nothing but checkout. That is the same conclusion the built-in WooCommerce webhooks reach — they queue deliveries rather than sending them in the request that created the order.
@since tag and parameters verified in class-wc-checkout.php against WooCommerce 10.8.1.