Saving Custom Checkout Data to a WooCommerce Order
The hook belongs to the classic checkout and never fires for the checkout block. On the stores where it does fire, the common snippet writes post meta that a high-performance order store cannot see.
This hook is classic-checkout only, and most of the snippets written for it are no longer safe to copy. woocommerce_checkout_update_order_meta fires inside WC_Checkout::create_order() after the order has been saved, passing the new order ID and the posted checkout data. Two things have changed around it since those snippets were written: the checkout block does not run it, and high-performance order storage means update_post_meta() writes to the wrong place.
What it receives
add_action( 'woocommerce_checkout_update_order_meta', 'ti_save_delivery_slot', 10, 2 );
function ti_save_delivery_slot( $order_id, $data ) {
if ( empty( $_POST['ti_delivery_slot'] ) ) {
return;
}
$order = wc_get_order( $order_id );
$order->update_meta_data( '_ti_delivery_slot', sanitize_text_field( wp_unslash( $_POST['ti_delivery_slot'] ) ) );
$order->save();
}
$data is the array of checkout fields WooCommerce itself knows about — billing, shipping, order notes, payment method — after sanitisation. A custom field printed into the form is not in $data unless it was registered through woocommerce_checkout_fields, which is why the example reads $_POST directly. Registering the field is the better path: it gets validation, it appears in $data, and the value survives the checkout's own AJAX refreshes.
The wc_get_order(), update_meta_data(), save() sequence is the storage-agnostic one. It costs a second write to the order, which is the price of a hook that fires after the first save.
The earlier hook that avoids the second write
woocommerce_checkout_create_order fires in the same method, before the order is saved, and receives the order object:
add_action( 'woocommerce_checkout_create_order', 'ti_set_delivery_slot', 10, 2 );
function ti_set_delivery_slot( WC_Order $order, $data ) {
if ( ! empty( $_POST['ti_delivery_slot'] ) ) {
$order->update_meta_data( '_ti_delivery_slot', sanitize_text_field( wp_unslash( $_POST['ti_delivery_slot'] ) ) );
}
}
No save() call: the checkout saves the order a moment later with your meta included. Everything that woocommerce_checkout_update_order_meta can do, this hook does earlier and cheaper. The older hook remains for code written before the order object was passed around, and for plugins that need the order ID to exist — a log entry, a call to an external system — which is a narrower set of cases than its popularity suggests.
The checkout block does not fire either hook
The block checkout posts to the Store API, and the Store API builds the order in its own controller. Neither woocommerce_checkout_create_order nor woocommerce_checkout_update_order_meta runs there. The Store API equivalent is:
add_action( 'woocommerce_store_api_checkout_update_order_from_request', 'ti_block_delivery_slot', 10, 2 );
function ti_block_delivery_slot( WC_Order $order, WP_REST_Request $request ) {
$extensions = $request->get_param( 'extensions' );
$slot = $extensions['ti']['delivery_slot'] ?? '';
if ( $slot ) {
$order->update_meta_data( '_ti_delivery_slot', sanitize_text_field( $slot ) );
}
}
The value does not arrive in $_POST; it arrives in the request's extensions parameter, which means the field had to be registered as a checkout block extension with its own schema to get there. That is a different and larger task than printing an input, and it is the real reason "my checkout field stopped working" reports spike after the switch to blocks. The symptom on the order side is covered in checkout not working; the cause is the hook, not the checkout.
A store that must support both checkouts needs both hooks. A store that uses only one can drop the other, but should know which one it uses: the classic checkout is the [woocommerce_checkout] shortcode, and the block is the Checkout block on the page, and a theme switch or a page rebuild can change the answer.
HPOS and the meta you already wrote
Stores that enabled high-performance order storage with compatibility mode keep the posts table in sync for a while, which hides the update_post_meta() mistake: the meta is written to the post, synced to the order table, and everything looks fine until sync is turned off. Once it is off, meta written with the post functions is invisible to $order->get_meta(), to the admin order screen, and to every email template. Check with a fresh test order after disabling sync, not before.
Verify
Place a classic-checkout test order with the field filled and open the order in the admin — the value should be in the custom fields panel or wherever you display it. Place the same order through the checkout block; if the value is missing, the Store API hook is the one you have not written yet. Finally, with HPOS enabled and sync disabled, confirm $order->get_meta( '_ti_delivery_slot' ) returns the value in a snippet run from an order note or a WP-CLI shell. Three passes mean the data will be there when fulfilment reads it.
Frequently asked
- The checkout block submits through the Store API, which builds the order along a different path and never calls woocommerce_checkout_update_order_meta. The equivalent hook is woocommerce_store_api_checkout_update_order_from_request, which passes the order object and the REST request.
- No. With high-performance order storage enabled the order is not a post, and update_post_meta writes to the posts meta table that the order no longer reads. Use $order->update_meta_data() followed by $order->save(), which works with either storage mode.
- Usually. It runs before the order is first saved and receives the order object, so setting meta there is written with the initial save and costs no second write. woocommerce_checkout_update_order_meta runs after the save and exists mostly for compatibility with older code.