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.
/ 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
/ 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.