{ "woocommerce" :
Dutch company and VAT fields in WooCommerce checkout
Short answer
WooCommerce has no KVK or VAT registry field. You add one yourself: on classic checkout through the woocommerce_billing_fields filter, validated in woocommerce_after_checkout_validation and written to order meta. We do not ship a WooCommerce plugin. We ship a REST API and a widget that you call from those hooks.
# server-side check at checkout
curl ".../v1/lookup/68750110" \
-H "Authorization: Bearer KEY"
{
"name": "Acme B.V.",
"isActive": true,
"vat": {
"number": "NL123456789B01",
"valid": true
}
}
Where the company data has to land
WooCommerce has no registry field. It has billing_company, which is free text. The moment you sell B2B in the Netherlands you need three places for the data to end up.
| Location | What belongs there | Why |
|---|---|---|
| Checkout field | KVK number, typed or picked | Input and validation |
| Order meta | KVK number, statutory name, VAT number | Invoice and bookkeeping |
| Customer meta | The same, as a default | Second order without retyping |
The part teams skip: an order is a snapshot. If you store only the KVK number and re-fetch the name later, your 2024 invoice stops matching reality after the company renames in 2026. Write the name and address onto the order, not just the key.
Classic checkout: field, validation, storage
Three hooks, in this order. Put them in a site-specific plugin rather than in the functions.php of a theme you will replace later.
add_filter( 'woocommerce_billing_fields', function ( $fields ) {
$fields['billing_kvk'] = [
'label' => 'KVK number',
'required' => false,
'class' => [ 'form-row-wide' ],
'priority' => 35,
];
return $fields;
} );
add_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {
$kvk = trim( $data['billing_kvk'] ?? '' );
if ( $kvk === '' ) {
return;
}
if ( ! preg_match( '/^\d{8}$/', $kvk ) ) {
$errors->add( 'billing_kvk', 'A KVK number is 8 digits.' );
return;
}
$res = wp_remote_get( "https://api.kvkbase.nl/v1/lookup/{$kvk}", [
'timeout' => 3,
'headers' => [ 'Authorization' => 'Bearer ' . KVKBASE_KEY ],
] );
if ( is_wp_error( $res ) || wp_remote_retrieve_response_code( $res ) >= 500 ) {
return; // API unreachable: let the order through, flag it afterwards.
}
if ( wp_remote_retrieve_response_code( $res ) === 404 ) {
$errors->add( 'billing_kvk', 'This KVK number is not in the trade register.' );
}
}, 10, 2 );
add_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {
if ( ! empty( $data['billing_kvk'] ) ) {
$order->update_meta_data( '_billing_kvk', wc_clean( $data['billing_kvk'] ) );
}
}, 10, 2 );
The early return on a 5xx or a transport error is deliberate. A checkout that blocks because a third party is slow costs you revenue. Let the order through and attach an order note or a status flag for manual review.
The blocks checkout is a second implementation
Now that the blocks checkout is the default, the form runs on React and the classic filters never fire. There you register the field through the Additional Checkout Fields API on woocommerce_init, under your own namespace. Two things to know:
- The value is stored under a namespaced meta key, not under
_billing_kvk. Any report or invoice plugin reading your old key returns empty columns. - Server-side validation attaches to that API’s own validation hooks, not to
woocommerce_after_checkout_validation.
If you run both checkouts side by side, write to the same custom meta key in both paths. Otherwise you build your reporting twice.
The widget as an input helper
If you want customers to search by company name instead of typing a number, attach the widget to the input. One script tag, with CSS selectors for the fields it may fill:
<script
src="https://widget.kvkbase.nl/v1/widget.js"
data-apikey="PUBLIC_KEY"
data-target="#billing_kvk"
data-autofill="true"
data-name-target="#billing_company"
data-city-target="#billing_city"
data-postal-target="#billing_postcode"
data-vat-target="#billing_vat"
></script>
The widget fills fields in the browser. That is convenience, not validation: anything a browser fills, a browser can edit. Do the check that matters server-side in the hook above, with a key that is not in your page source. The data-apikey is public by definition.
On classic checkout this works as-is. On the blocks checkout the input does not exist yet when the script loads, so a plain selector matches nothing. Attach the widget after the field renders, or call the API directly from your own block component.
What actually breaks in production
The customer pastes a branch number. Dutch invoices and confirmation mails often show the 12-digit vestigingsnummer instead of the 8-digit KVK number. Your regex catches it, but the error message has to say which number you want, or the customer gives up.
A real company returns 404. Deregistered entries drop out of the register. Treat NOT_FOUND as “no longer active” rather than “typo”, and let an existing customer continue with a manual review flag.
VIES is unreachable. VAT validation runs against the member state service and that service goes down regularly. You get UPSTREAM_ERROR and no valid: true. Never block checkout on it: charge VAT, process the order, and correct afterwards if the reverse charge turns out to apply. The mechanics are covered in VAT number validation with the VIES API.
A bulk backfill hits the rate limit. Enriching existing orders with a foreach over 3,000 customers exhausts the per-minute limit within seconds. Use POST /v1/lookup/batch and process in chunks from a cron action instead of one lookup per row.
Frequently asked
Do you have a WooCommerce plugin?
Should the KVK field be required?
Does the same code work in the blocks checkout?
Can I derive the VAT number from the KVK number?
Updated:
One call gives you the whole company
KVK data, the derived VAT number and a live VIES check, in a single request. The free plan covers 50 lookups a month and needs no card.
- 50
- free lookups a month
- 1
- request instead of three
- 0
- cards, contracts or sales calls