The Problem
I ran into this on a client project last week. A B2B WooCommerce store had a "Purchase Order Number" field on the legacy shortcode checkout. They migrated to the Block Checkout to fix a different bug and the PO field disappeared from the saved order. The form rendered the input, the user typed into it, the order placed cleanly, and the value was nowhere — not in the order meta, not in the email, not in the admin sidebar.
If you added a custom field to the WooCommerce Block Checkout with woocommerce_form_field(), add_action( 'woocommerce_after_order_notes', ... ), or any of the classic checkout hooks and the value never persists to the order, this is the pattern. The Block Checkout is a React app talking to the Store API, not a PHP form post. The old hooks do not run.
Why It Happens
The legacy WooCommerce checkout was a <form method="post">. Custom fields hooked into the form HTML and read $_POST in woocommerce_checkout_update_order_meta. None of that exists in the Block Checkout.
Three things break together:
- The Block Checkout renders React on the client. Your
woocommerce_form_field()PHP call still outputs HTML in the DOM, but the React tree does not control that input. When the Store API submits the order it sends a JSON payload built from@wordpress/datastores. Inputs React does not own are invisible to the submission. - There is no
$_POSTon submit. Order placement goes throughPOST /wp-json/wc/store/v1/checkoutwith a JSON body. Classic actions likewoocommerce_checkout_update_order_meta,woocommerce_checkout_process, andwoocommerce_after_checkout_validationdo not fire at all on Block Checkout. - The Store API rejects unknown fields. Even if you patch the React tree to send extra JSON keys, the schema validates the request and drops anything it does not recognise. Silently. No 400, no warning, just an order with no extra data.
The bridge between your input and the order is ExtendSchema, an extensibility layer the WooCommerce Blocks team added for exactly this scenario. You have to register your namespace, declare the schema, and read it on the server.
The Fix
There are two halves: register an extension on the PHP side so the Store API accepts your field, and render the input on the React side so the value flows through the existing @wordpress/data store. Skip either half and it fails.
Step 1: Register the extension and schema on the server. Drop this in a small mu-plugin or your theme's functions.php:
<?php
use Automattic\WooCommerce\StoreApi\StoreApi;
use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
add_action( 'woocommerce_blocks_loaded', function () {
$extend = StoreApi::container()->get( ExtendSchema::class );
$extend->register_endpoint_data( [
'endpoint' => CheckoutSchema::IDENTIFIER,
'namespace' => 'acme/po',
'schema_callback' => function () {
return [
'po_number' => [
'description' => __( 'Internal purchase order number', 'acme' ),
'type' => [ 'string', 'null' ],
'context' => [ 'view', 'edit' ],
'readonly' => false,
],
];
},
] );
} );
add_action( 'woocommerce_store_api_checkout_update_order_from_request',
function ( $order, $request ) {
$data = $request['extensions']['acme/po'] ?? null;
if ( ! is_array( $data ) ) {
return;
}
$po = isset( $data['po_number'] ) ? sanitize_text_field( $data['po_number'] ) : '';
if ( $po !== '' ) {
$order->update_meta_data( '_acme_po_number', $po );
$order->save();
}
},
10, 2
);
The register_endpoint_data call tells the Store API that requests to /checkout are allowed to carry an extensions.acme/po object with a po_number string. The woocommerce_store_api_checkout_update_order_from_request action is the Block Checkout equivalent of woocommerce_checkout_update_order_meta. It runs after validation, before payment processing, and gives you the $order and the parsed $request.
Step 2: Render the React input and push to the store. Build a tiny JS bundle and enqueue it on the checkout page:
import { registerCheckoutBlock } from '@woocommerce/blocks-checkout';
import { useEffect, useState } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data';
const PoField = () => {
const [ value, setValue ] = useState( '' );
const { setExtensionData } = useDispatch( CHECKOUT_STORE_KEY );
useEffect( () => {
setExtensionData( 'acme/po', { po_number: value } );
}, [ value, setExtensionData ] );
return (
<div className="wc-block-components-text-input">
<label htmlFor="acme-po">Purchase Order Number</label>
<input
id="acme-po"
type="text"
value={ value }
onChange={ ( e ) => setValue( e.target.value ) }
/>
</div>
);
};
registerCheckoutBlock( {
metadata: {
name: 'acme/po-field',
parent: [ 'woocommerce/checkout-fields-block' ],
},
component: PoField,
} );
setExtensionData( 'acme/po', { po_number: value } ) is the key. It writes into the checkout store's extensions slice under your namespace, and the Store API serialises that into the extensions object on the order request. The namespace string must match the PHP register_endpoint_data namespace exactly.
Step 3: Verify in the network tab. Place a test order with the field filled in. Open DevTools, find the POST /wp-json/wc/store/v1/checkout request, and look at the JSON body. You should see:
{
"billing_address": { /* ... */ },
"shipping_address": { /* ... */ },
"payment_method": "stripe",
"extensions": {
"acme/po": { "po_number": "PO-44218" }
}
}
If the extensions.acme/po block is missing, the React bundle did not register or setExtensionData ran with an empty string. If the block is present but the order has no _acme_po_number meta, the PHP namespace string does not match. Those are the only two failure modes, and they map cleanly to the two halves of the fix.
The Lesson
The Block Checkout is a different runtime from the legacy checkout, not a re-skin. Custom fields require a Store API extension with register_endpoint_data, a server hook on woocommerce_store_api_checkout_update_order_from_request, and a React block that calls setExtensionData. Once those three pieces share a namespace, the data flows.
If you are mid-migration off the shortcode checkout and the custom-field rewrite is blocking you, that is the kind of work I do. See my services. For another Block Checkout bug pattern I covered, see WooCommerce checkout block payment methods missing.