WP Webhooks / Blog / WordPress internals

When woocommerce_checkout_order_created Actually Fires

woocommerce_checkout_order_created documentation: the signature, where it sits in the checkout hook sequence, and why it never fires on the blocks checkout.

8 min 2026-07-31 Updated 2026-09-10
#WooCommerce#Hooks

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, a WC_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_created instead.
  • 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 );
ParameterTypeWhat it holds
$orderWC_OrderThe 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:

#HookParametersOrder saved?
1woocommerce_checkout_create_order$order, $dataNo — modify it here
2$order->save()The write itself
3woocommerce_checkout_update_order_meta$order_id, $dataYes
4woocommerce_checkout_order_created$orderYes
5woocommerce_checkout_order_processed$order_id, $posted_data, $orderYes — later in the request
FIG 01 — Where the hook sits, and the path that skips it

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 );
HookParameterTypeWhat it holds
woocommerce_checkout_create_order$orderWC_OrderThe unsaved order. Addresses, payment method and cart data are set; there is no ID yet.
$dataarrayThe posted checkout fields, already validated and sanitised: billing_*, shipping_*, payment_method, order_comments.
woocommerce_checkout_create_order_line_item$itemWC_Order_Item_ProductThe line item, with product, quantity, subtotal and total set, not yet added to the order.
$cart_item_keystringThe cart hash for this item — the key into WC()->cart->get_cart().
$valuesarrayThe raw cart item array: product_id, variation_id, quantity, line totals, and any custom cart data a plugin added.
$orderWC_OrderThe 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
try_it

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:

HookFires whenCovers blocks?Covers admin orders?
woocommerce_checkout_order_createdOrder row written, inside create_order()NoNo
woocommerce_checkout_order_processedLater in the checkout request, with posted dataNoNo
woocommerce_new_orderAny order is first persisted, from any sourceYesYes
woocommerce_order_status_processingOrder reaches the processing statusYesYes

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.

/Footnotes
¹ Hook position, @since tag and parameters verified in class-wc-checkout.php against WooCommerce 10.8.1.
² Store API equivalents and the shortcode-only rule from the WooCommerce hook alternatives reference.
FAQ

Things engineers always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

What is woocommerce_checkout_order_created? +
It is a WooCommerce action that fires inside WC_Checkout::create_order() immediately after the order has been saved to the database. It was introduced in WooCommerce 4.3.0 and receives a single parameter, the WC_Order object, which already has a real order ID at that point.
What is the signature of woocommerce_checkout_order_created? +
do_action('woocommerce_checkout_order_created', $order) — one parameter, a WC_Order instance. Because it passes the object rather than an ID, a callback can read totals, items and addresses without calling wc_get_order() first.
Why does woocommerce_checkout_order_created not fire on the blocks checkout? +
Because the blocks checkout does not go through WC_Checkout::create_order() at all. It posts to the Store API, which fires its own parallel actions, woocommerce_store_api_checkout_order_created and woocommerce_store_api_checkout_order_processed. WooCommerce documents that shortcode checkout hooks do not run on Store API requests.
What is the difference between woocommerce_checkout_order_created and woocommerce_checkout_order_processed? +
Timing and payload. order_created fires inside create_order() the moment the order row exists and passes only $order. order_processed fires later in the same request, after payment has been set up, and passes $order_id, $posted_data and $order. Use created for enrichment, processed when you need the posted checkout data.
Which hook should trigger an order webhook? +
For a notification that an order exists, woocommerce_checkout_order_created is the earliest safe point, but it misses blocks and admin-created orders. If you need every order regardless of origin, bind to a status transition such as woocommerce_order_status_processing instead, and mirror the Store API hook when you specifically want checkout-time behaviour.
Ready

Your next automation is
one sentence away.

$ wp plugin install flowsystems-webhook-actions --activate