WP Webhooks / Blog / WordPress internals
Article · 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
#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
woocommerce_checkout_order_created in the shortcode and blocks checkout pathsOn the shortcode checkout, WC_Checkout::create_order builds the order object, fires woocommerce_checkout_create_order before saving, saves the order, fires woocommerce_checkout_update_order_meta, and then fires woocommerce_checkout_order_created with the saved order. Later in the same request woocommerce_checkout_order_processed fires with the order id, the posted data and the order. The blocks checkout goes through the Store API instead and fires the parallel woocommerce_store_api_checkout_order_created and woocommerce_store_api_checkout_order_processed actions, so a callback bound only to the shortcode hook never runs for a blocks order.

shortcode [woocommerce_checkout]

blocks checkout

customer places an order

which checkout?

WC_Checkout::create_order()

woocommerce_checkout_create_order
( $order, $data )

$order->save()

woocommerce_checkout_update_order_meta
( $order_id, $data )

woocommerce_checkout_order_created
( $order )

woocommerce_checkout_order_processed
( $order_id, $posted_data, $order )

Store API route

woocommerce_store_api_checkout_order_created
( $order )

woocommerce_store_api_checkout_order_processed
( $order )

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.

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

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