Migrating an enterprise store to the Gutenberg-based Block Checkout usually fails at one point. The VAT number field that worked for years stops saving, the compliance notes disappear from the order screen, and the PHP hooks the team relied on do nothing. This post covers the architecture behind that failure and the supported way to fix it. It is aimed at senior developers who need a woocommerce_store_api_register_endpoint_data checkout block custom field implementation that survives schema validation, HPOS, and future WooCommerce releases.
Why classic checkout hooks fail on the Block Checkout
Classic checkout is a server-rendered PHP template. woocommerce_before_checkout_form prints markup into that template, and woocommerce_checkout_update_order_meta reads $_POST when the form is submitted.
The Block Checkout has no PHP template and no $_POST submission. It is a React application that renders from a JSON state tree and submits JSON to a REST endpoint. Two consequences follow:
- Hooks that print HTML (
woocommerce_before_checkout_form,woocommerce_after_order_notes) never run in the block's render path. - Hooks that read
$_POST(woocommerce_checkout_update_order_meta,woocommerce_checkout_process) never see your field, because the data is not posted as form fields.
Agencies find this out late. The field disappears silently, with no PHP warning and no failed request. On a B2B store the cost is concrete:
- Tax treatment: intra-EU reverse-charge invoicing depends on a verified VAT number being attached to the order.
- Compliance: delivery instructions, import declarations, and purchase-order references often have contractual or regulatory weight.
- Accounting sync: ERP integrations that read order meta break when the meta is never written.
The fix is to move the data into the Store API contract rather than patching template hooks.
The dual-layer architecture
The Block Checkout is two coupled layers:
- A React frontend that keeps its state in
@wordpress/datastores. The cart store (wc/store/cart) holds the cart payload, including anextensionsobject. The checkout store holds the order submission state. - A strictly typed Store API backend, exposed through
wc/store/v1/cartandwc/store/v1/checkout. These endpoints allow unauthenticated, cookie-based access: the session cookie identifies the shopper, and a nonce or Cart-Token header authorizes writes.
The Store API validates request bodies against a declared JSON schema. Core fields are defined by WooCommerce. Anything custom must live under the extensions key, namespaced per plugin, and must be registered. Unregistered namespaces or properties are rejected or stripped, so a field that React sends but PHP never declared does not reach your order.
That is the purpose of woocommerce_store_api_register_endpoint_data(). It declares your namespace, the shape of its data, and a callback that supplies current values, so the REST layer can serialize it on reads and validate it on writes. Behind this registration function is WooCommerce's ExtendSchema class, a shared Store API extensibility mechanism that allows developers to expose plugin-specific data and schema definitions through existing cart and checkout endpoints without modifying WooCommerce core.
Registering endpoint data and schema
Registration must happen on woocommerce_blocks_loaded. Calling the function earlier risks a fatal error because the Store API classes (CheckoutSchema, CartSchema, the extend controller) are not loaded yet.
Register the namespace on both the cart and checkout endpoints. The cart registration is what populates getCartData().extensions in the frontend store. The checkout registration is what lets the checkout POST carry and validate your data.
<?php
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CheckoutSchema;
add_action( 'woocommerce_blocks_loaded', 'ent_register_b2b_store_api' );
function ent_register_b2b_store_api() {
if ( ! function_exists( 'woocommerce_store_api_register_endpoint_data' ) ) {
return;
}
foreach ( array( CartSchema::IDENTIFIER, CheckoutSchema::IDENTIFIER ) as $endpoint ) {
woocommerce_store_api_register_endpoint_data( array(
'endpoint' => $endpoint,
'namespace' => 'enterprise_b2b',
'data_callback' => 'ent_get_b2b_data',
'schema_callback' => 'ent_get_b2b_schema',
'schema_type' => ARRAY_A,
) );
}
}
function ent_get_b2b_data() {
$session = function_exists( 'WC' ) ? WC()->session : null;
return array(
'vat_number' => $session ? (string) $session->get( 'b2b_vat_number', '' ) : '',
);
}
function ent_get_b2b_schema() {
return array(
'vat_number' => array(
'description' => __( 'Verified European VAT number.', 'text-domain' ),
'type' => 'string',
'context' => array( 'view', 'edit' ),
'readonly' => false,
'arg_options' => array(
'sanitize_callback' => 'sanitize_text_field',
),
),
);
}
JSON Schema validation is an important part of the WooCommerce Store API contract. The schema_callback defines the expected data types and properties of registered extension fields, while additional business rules, such as VAT number verification, must still be enforced through server-side checkout validation.
Four details matter here:
schema_typeofARRAY_Atells the controller the callbacks return associative arrays, which it then maps onto the schema.readonly => falseis required for writable fields. Areadonlyproperty is treated as output-only and is dropped from incoming requests, which is a common cause of "my value never arrives" bugs.- The data callback must be cheap and side-effect free. It runs on every cart and checkout response, including each shipping-rate recalculation.
- Guard the session. In some REST contexts the session object is not initialized, so the null check prevents warnings.
Once registered, any cart or checkout response includes the following:
{
"extensions": {
"enterprise_b2b": {
"vat_number": "DE123456789"
}
}
}
This is an example of RESTful API serialization, where WooCommerce converts registered server-side extension data into a structured JSON response. The React frontend can then retrieve the values through the extensions.enterprise_b2b namespace, maintaining a consistent data contract between the PHP backend and the JavaScript application.
Consuming and submitting the data in React
Reading the exposed object
Read extensions.enterprise_b2b from the cart store with useSelect:
import { useSelect } from '@wordpress/data';
import { useState, useEffect } from '@wordpress/element';
import { TextControl } from '@wordpress/components';
import { extensionCartUpdate } from '@woocommerce/blocks-checkout';
const NAMESPACE = 'enterprise_b2b';
export const VatNumberField = () => {
const serverValue = useSelect( ( select ) => {
const cart = select( 'wc/store/cart' ).getCartData();
return cart?.extensions?.[ NAMESPACE ]?.vat_number ?? '';
}, [] );
const [ vat, setVat ] = useState( serverValue );
useEffect( () => setVat( serverValue ), [ serverValue ] );
const commit = () => {
// Persist server-side and trigger totals/shipping recalculation.
extensionCartUpdate( {
namespace: NAMESPACE,
data: { vat_number: vat.trim().toUpperCase() },
} );
};
return (
<TextControl
label="EU VAT number"
value={ vat }
onChange={ setVat }
onBlur={ commit }
/>
);
};
extensionCartUpdate posts to the cart update route. It requires a matching update callback on the server, registered with woocommerce_store_api_register_update_callback. The callback writes the value to the session and the cart is recalculated. This is also how a custom field can trigger shipping and tax updates:
add_action( 'woocommerce_blocks_loaded', function () {
woocommerce_store_api_register_update_callback( array(
'namespace' => 'enterprise_b2b',
'callback' => function ( $data ) {
if ( isset( $data['vat_number'] ) ) {
WC()->session->set(
'b2b_vat_number',
sanitize_text_field( $data['vat_number'] )
);
}
},
) );
} );
For the field to render inside the checkout, mount it with an inner block or a slot fill registered through the checkout extensibility API. Which mechanism fits depends on the layout you need, and the component above works with either. SlotFill checkout extensibility is part of the Gutenberg and WooCommerce Blocks architecture, allowing React components to be inserted into supported checkout interface locations. While SlotFill controls where the custom field appears, Store API registration and checkout processing determine how its data reaches the backend and is persisted.
Including the data in the checkout submission
Values stored in the session are not enough on their own. The final order submission must carry the data in the request body so it can be validated against the checkout schema. In a block, set it through the checkout extension data hook, which writes into the extensions object of the POST payload:
import { useCheckoutExtensionData } from '@woocommerce/blocks-checkout';
const { setExtensionData } = useCheckoutExtensionData();
setExtensionData( 'enterprise_b2b', 'vat_number', vat );
The checkout POST then contains:
{
"billing_address": { "...": "..." },
"payment_method": "stripe",
"extensions": {
"enterprise_b2b": { "vat_number": "DE123456789" }
}
}
The names exported by @woocommerce/blocks-checkout have moved between releases, so check them against the WooCommerce version you target.
Validating and persisting to HPOS
On the server, hook woocommerce_store_api_checkout_update_order_from_request. It receives the WC_Order and the WP_REST_Request before the order is finalized. Validate here, and reject bad input with a RouteException so the checkout shows a clean error instead of creating a bad order.
<?php
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
add_action(
'woocommerce_store_api_checkout_update_order_from_request',
'ent_persist_b2b_data',
10,
2
);
function ent_persist_b2b_data( $order, $request ) {
$extensions = $request->get_param( 'extensions' );
$vat = $extensions['enterprise_b2b']['vat_number'] ?? '';
$vat = strtoupper( preg_replace( '/\s+/', '', sanitize_text_field( $vat ) ) );
if ( '' === $vat ) {
return; // Field is optional for B2C buyers.
}
// Format check; replace or extend with a VIES lookup for full verification.
if ( ! preg_match( '/^[A-Z]{2}[A-Z0-9]{2,12}$/', $vat ) ) {
throw new RouteException(
'ent_invalid_vat',
__( 'The VAT number format is invalid.', 'text-domain' ),
400
);
}
// CRUD methods are HPOS-safe; never use update_post_meta() on orders.
$order->update_meta_data( '_enterprise_vat_number', $vat );
}
Use the order CRUD API (update_meta_data) rather than update_post_meta(). With High-Performance Order Storage enabled, orders live in custom tables, and direct post meta calls write to the wrong place or nowhere. The CRUD layer resolves the correct storage backend, so the same code works with HPOS on or off. This High-Performance Order Storage (HPOS) CRUD API approach ensures that custom order metadata is handled through WooCommerce's supported data abstraction layer. Methods such as $order->update_meta_data() and $order->save() allow extensions to work with the active order data store without directly manipulating legacy wp_postmeta records or HPOS custom order tables. If you call a VIES web service in the validation step, apply a short timeout and decide up front whether a service outage should block the sale or flag the order for manual review.
Conclusion
A woocommerce_store_api_register_endpoint_data checkout block custom field implementation works because it follows the contract the Block Checkout enforces. Formally registered schemas let the Store API:
- serialize your data on reads, so React sees it under
extensions.enterprise_b2b; - validate and sanitize your data on writes, so malformed payloads fail with a clear REST error instead of silently disappearing;
- keep working as WooCommerce changes its internals, because the extension contract is the supported surface and template hooks are not.
The pattern has four parts: register on woocommerce_blocks_loaded, declare a typed schema for both endpoints, send data through extensions, and persist it with HPOS-safe CRUD calls in woocommerce_store_api_checkout_update_order_from_request. Treat any custom checkout field that skips these steps as technical debt.


0 Comments