WooCommerce's block-based checkout is now the default checkout on new stores, and it breaks a lot of familiar advice. Most tutorials still show the classic checkout hooks, such as the woocommerce_checkout_fields filter or woocommerce_after_order_notes, and none of them affect the block checkout. If you added a field that way and it never appeared, that's why.
I ran into this on a client project that needed an extra reference number at checkout. This guide shows the approach that works: WooCommerce's Additional Checkout Fields API. It covers where fields can go, how to register them, how to validate them, and how to read the values back in your own code.
Where you can add custom fields
The block checkout lets you place a custom field in one of three locations:
| Location value | Where it appears | Typical use |
|---|---|---|
contact |
Contact information, under the email address | Alternative email, customer reference, VAT number |
address |
Inside the shipping and billing address forms | Delivery instructions, building access code |
order |
Additional order information, before the order note | "How did you hear about us?", purchase-order number |
One thing to keep in mind: an address field is shown in both the shipping and the billing address, and WooCommerce stores a separate value for each. You can't currently show it in only one of them.
Step 1: Register the field
Fields are registered in PHP with woocommerce_register_additional_checkout_field(). Call it on the woocommerce_init action, which is what the current WooCommerce docs recommend. Registering earlier can cause initialisation and translation problems.
Here is the field from my client project: an optional "Matter number" in the order section.
add_action( 'woocommerce_init', function () {
if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {
return; // WooCommerce is too old or not active.
}
woocommerce_register_additional_checkout_field(
array(
'id' => 'my-plugin/matter-number',
'label' => 'Matter number',
'optionalLabel' => 'Matter number (only if you have one)',
'location' => 'order', // 'contact', 'address' or 'order'
'required' => false,
'attributes' => array(
'autocomplete' => 'off',
'title' => 'Your matter number, if you already have one',
'pattern' => '[A-Za-z0-9]{5}', // optional: exactly 5 letters/numbers
'data-custom' => 'matter-number',
),
)
);
} );
Tip: the
idmust be unique and namespaced asnamespace/field-name. Use your plugin or theme slug as the namespace so the field never clashes with another extension.
To move the field to a different part of the checkout, change location to contact or address. No other change is needed.
When I first wrote this in May 2024, many examples used the woocommerce_blocks_loaded hook. Current WooCommerce versions recommend woocommerce_init, so use that for new code.
Field options you can use
| Option | Required | What it does |
|---|---|---|
id |
Yes | Unique ID in namespace/field-name format |
label |
Yes | Field label, also used as the placeholder |
optionalLabel |
No | Label shown when the field is optional (default: label + "(optional)") |
location |
Yes | contact, address or order |
type |
No | text (default), select, checkbox or date |
required |
No | true/false, or a JSON Schema rule for conditional logic |
hidden |
No | false, or a JSON Schema rule to hide the field conditionally |
attributes |
No | Extra HTML attributes (data-*, aria-*, autocomplete, pattern, title, maxLength, readOnly, …). Not supported on select |
sanitize_callback |
No | Cleans the submitted value |
validate_callback |
No | Checks the value and returns a WP_Error if it's invalid |
A dropdown (select) field
A select field needs an options array. Each option needs a unique value and a label. Only the value is sent to the server.
woocommerce_register_additional_checkout_field(
array(
'id' => 'my-plugin/how-did-you-hear',
'label' => 'How did you hear about us?',
'location' => 'order',
'type' => 'select',
'options' => array(
array( 'value' => 'google', 'label' => 'Google' ),
array( 'value' => 'referral', 'label' => 'A friend or colleague' ),
array( 'value' => 'other', 'label' => 'Other' ),
),
)
);
A select field has no default value, so if you make it required the customer must actively pick an option.
Step 2: Sanitise and validate the value
Never trust checkout input. Both callbacks are set per field. The sanitize callback runs first, and validation runs on the cleaned value:
woocommerce_register_additional_checkout_field(
array(
'id' => 'my-plugin/alt-email',
'label' => 'Alternative email for invoices',
'location' => 'contact',
'required' => false,
'sanitize_callback' => function ( $field_value ) {
return sanitize_email( $field_value );
},
'validate_callback' => function ( $field_value ) {
if ( '' !== $field_value && ! is_email( $field_value ) ) {
return new WP_Error( 'invalid_alt_email', 'Please enter a valid email address.' );
}
},
)
);
When validation returns a WP_Error, WooCommerce shows the message on the checkout and stops the order from being placed. That's exactly what you want for bad input.
Step 3: Read the saved value in your code
You'll usually need the value later, for example in an order email, an admin column or an export. Use WooCommerce's CheckoutFields service rather than guessing meta keys.
Address fields have a billing and a shipping value:
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
$field_id = 'my-plugin/delivery-notes';
$order = wc_get_order( $order_id );
$checkout_fields = Package::container()->get( CheckoutFields::class );
$billing_value = $checkout_fields->get_field_from_object( $field_id, $order, 'billing' );
$shipping_value = $checkout_fields->get_field_from_object( $field_id, $order, 'shipping' );
Contact and order fields are stored together in the "other" group. The simplest reliable way to read them is to get all of them at once:
$checkout_fields = Package::container()->get( CheckoutFields::class );
$other_fields = $checkout_fields->get_all_fields_from_object( $order, 'other' );
$matter_number = $other_fields['my-plugin/matter-number'] ?? '';
Address field values can also be read from a customer object, for example wc()->customer, by passing it in place of the order. During checkout, use wc()->customer rather than new WC_Customer( $id ): a logged-in customer's saved data is only updated once the order is placed, so a fresh customer object can return old values.
Limitations to know before you start
- The API is for the block checkout. If a store still uses the classic
[woocommerce_checkout]shortcode, you need the classic hooks there (or switch the store to the block checkout). - You can't choose the meta key. WooCommerce stores the values under its own prefixed keys, so read them with the methods above.
- Field types are limited to text, select, checkbox and date for now.
- Address fields always appear in both billing and shipping.
Quick checklist
- Make sure the store's checkout page uses the Checkout block, not the shortcode.
- Register the field on
woocommerce_initwith a namespacedid. - Pick the right
location:contact,addressororder. - Add
sanitize_callbackandvalidate_callbackfor anything customers type. - Read values with
get_field_from_object()orget_all_fields_from_object(). - Test a guest checkout and a logged-in checkout, then check the order in wp-admin.
Further reading
Need custom checkout fields, conditional logic or a checkout integration built for your store? I build custom WooCommerce plugins for clients worldwide. Tell me about your project and I'll reply within a few hours.
