Validate Dutch VAT Numbers (BTW-id) in Node.js
14 August 2026

The Netherlands is a hub for EU B2B and a market most SaaS teams need to support early. The Dutch VAT ID — the btw-identificatienummer, or btw-id — has a fixed 14-character shape: NL + 9 digits + the literal letter B + 2 digits, e.g. NL001631457B01. It looks like a number you can validate locally, and for years you could. But in 2020 the Netherlands changed how sole-trader btw-ids are issued, and that quietly broke the usual local-checksum trick for a large class of numbers. So unlike the German, French, and Polish guides in this series, the Dutch pipeline leans harder on VIES — with the KVK company register as a fallback. Here is the full pipeline in Node.js, and why the Netherlands is the odd one out.
What a Dutch VAT number looks like
The Dutch VAT ID has a rigid structure:
NL— country codeNNNNNNNNN— a 9-digit blockB— a literal letter, alwaysBNN— a 2-digit sequence number
So NL001631457B01 is NL + 001631457 + B + 01. Two details trip people up:
- The trailing two digits are a sequence number, not a checksum. They identify which business belongs to the holder:
B01is the first business,B02andB03are further businesses of the same person or entity. Do not treat these digits as check digits — there is nothing to verify in them. - There are two different Dutch numbers, and only one belongs in VIES. Since 2020 every sole trader has both:
- the btw-id (
NL…B01) — public, printed on invoices and websites, and the one you check in VIES; - the omzetbelastingnummer (also called the btw-nummer), which is BSN-based (
123456789B01) — private, for correspondence with the Dutch tax office only, and not valid in VIES.
- the btw-id (
The risk here is not a REGON-style sibling-registry mix-up like in Poland. The specific Dutch trap is a customer pasting their private BSN-based omzetbelastingnummer where the public btw-id belongs. Both share the …Bnn shape, so a regex alone will not catch the swap — VIES will simply return "invalid" for the private number.
Step 1: format validation
Reject malformed input before any network call. As with the sibling guides, decide up-front which mode the validator runs in:
- Strict (API mode) — your service exposes a documented
NL+ 9 digits +B+ 2 digits format. Reject anything else. - Lenient (checkout mode) — accept what customers type and try to make it work. Spaces, dots, a lowercase
b, and a missingNLprefix are common; auto-correct them and log the original input.
const NL_VAT_PATTERN = /^NL\d{9}B\d{2}$/
function normaliseDutchVatId(
input: string,
opts: { mode?: 'strict' | 'lenient' } = {}
): string | null {
let cleaned = input.replace(/[\s.\-]/g, '').toUpperCase()
// Lenient mode: prepend NL when the user typed only the 12-char local part
if (opts.mode === 'lenient' && /^\d{9}B\d{2}$/.test(cleaned)) {
cleaned = `NL${cleaned}`
}
return NL_VAT_PATTERN.test(cleaned) ? cleaned : null
}
// Strict (default) — API consumers should send a fully-qualified ID
normaliseDutchVatId('NL 0016 3145 7 B01') // → 'NL001631457B01'
normaliseDutchVatId('001631457B01') // → null
// Lenient — for checkout forms
normaliseDutchVatId('001631457b01', { mode: 'lenient' }) // → 'NL001631457B01'
normaliseDutchVatId('NL001631457B0', { mode: 'lenient' }) // → null (wrong length)
The principle is the same Postel-style discipline that backs reasonable input handling everywhere: be liberal in what you accept at the regex layer, and strict in what you verify against an authoritative source. The regex proves shape only — that the B is present and the sequence suffix is two digits — it never proves registration. A string that passes NL_VAT_PATTERN is well-formed input, nothing more.
Step 2: why you can't rely on a local checksum here
This is where the Netherlands diverges from every other guide in the series. In Germany, France, and Poland you can run a local checksum as a cheap pre-flight filter. In the Netherlands you effectively cannot, and it is worth understanding exactly why.
Historically, the 9-digit block was a BSN (citizen service number) or RSIN (the legal-entity equivalent), and those satisfy the Dutch elfproef — a mod-11 test using weights [9, 8, 7, 6, 5, 4, 3, 2, -1] where the weighted sum must be divisible by 11. If you only ever saw legal-entity numbers, a mod-11 gate looked like a reliable filter.
Then, in October 2019, roughly 1.3 million sole proprietors (eenmanszaken) were issued a brand-new btw-id, and effective 1 January 2020 the 9-digit block of a sole-trader btw-id is randomized and decoupled from the BSN. Those randomized numbers use a different scheme and do not satisfy the elfproef. That is the whole point of the change — the public btw-id can no longer be reverse-engineered into someone's citizen number.
The consequence for your validator is blunt: a mod-11 check now rejects a large, legitimate class of Dutch VAT numbers. You can still compute the elfproef, but only to understand why it is no longer a general validator:
// Illustrative ONLY — do NOT use this to reject a Dutch VAT number.
// Post-2020 sole-trader btw-ids are randomized and will fail this test
// even though they are perfectly valid. It passes for legacy BSN/RSIN-based
// numbers and fails for the randomized ones, which is exactly why it is
// useless as a general gate.
function passesElfproef(vatId: string): boolean | null {
const match = /^NL(\d{9})B\d{2}$/.exec(vatId)
if (!match) return null
const digits = match[1]
const weights = [9, 8, 7, 6, 5, 4, 3, 2, -1]
const sum = weights.reduce((acc, w, i) => acc + w * Number(digits[i]), 0)
return sum % 11 === 0
}
Do not gate Dutch validation on a local checksum. The 2020 sole-trader change means a correct btw-id can fail the elfproef, so rejecting on it will block real customers. Skip the local checksum entirely and let VIES be the authoritative check.
So for the Netherlands the pipeline is shorter by one step: validate the format, then go straight to VIES.
Step 3: VIES — and how the Netherlands behaves
When you query an NL number through VIES, the European Commission routes the request to the Dutch tax administration (Belastingdienst). The result your application sees is one of:
- A valid/invalid answer. For Dutch numbers, VIES typically returns the trader name and address when valid — but treat that as observed behaviour, not a guarantee. Any member state may withhold trader details, and the NL node can be unavailable or time out.
MS_UNAVAILABLE(the Dutch node is temporarily unreachable)SERVICE_UNAVAILABLE(VIES itself is degraded)- A timeout after ~10 seconds
No national node is online 100% of the time — the VIES downtime guide walks through the failure modes you should design for across all member states. Because you cannot fall back to a local checksum in the Netherlands, VIES availability matters more here than elsewhere, which makes the fallback path below worth wiring up.
Step 4: KVK fallback
When VIES NL is unavailable, the Dutch analogue to France's SIRENE is KVK (Kamer van Koophandel), the national company register. Every registered Dutch business has a KVK number, and KVK exposes a public API at developers.kvk.nl that is searchable by KVK number, RSIN, name, or address, and returns whether a company exists and is active along with its RSIN, name, and address.
Three things to be clear about up front:
- KVK is a company registry, not a VAT registry. It tells you whether a business exists and is active — it does not confirm intra-EU VAT registration. A Dutch company can be listed and active in KVK without being registered for cross-border VAT.
- The RSIN → KVK linkage works for legal entities only. You can look up a legal entity by its RSIN, and for legacy legal-entity btw-ids the 9-digit block is the RSIN. But a post-2020 sole-trader btw-id has no derivable RSIN — its 9 digits are randomized — so for sole traders you cannot bridge from the btw-id to a KVK record at all.
- KVK access has real barriers. The API requires an API key, and obtaining one in practice requires a Dutch-registered entity. That is a genuine reason to let a provider handle the fallback rather than standing up KVK access yourself.
Like the French SIRENE path, KVK is a fallback signal, not a cross-border verdict: if VIES NL is down and KVK confirms the entity exists and is active, you have a reasonable basis to proceed while a VIES re-check is queued.
async function validateDutchVat(vatId: string) {
const cleaned = normaliseDutchVatId(vatId)
if (!cleaned) {
return { valid: false, error: 'INVALID_FORMAT' }
}
try {
const vies = await callVies(cleaned)
if (vies.status === 'OK') {
return {
valid: vies.valid,
name: vies.name,
address: vies.address,
source: 'VIES',
}
}
} catch (e) {
// fall through to KVK
}
// VIES NL unavailable — the 9-digit block is an RSIN for legal entities only.
// Post-2020 sole-trader btw-ids have no derivable RSIN; skip the fallback for them.
const localId = cleaned.slice(2, 11) // 9-digit block
const kvk = await callKvk(localId)
return {
valid: kvk?.active ?? false,
name: kvk?.name,
address: kvk?.address,
source: 'KVK_NL',
note: 'company active in KVK; not a VAT verdict; VIES re-check queued',
}
}
There are three ways to cover the NL fallback path, fastest first:
- Use vatnode. It already runs VIES NL plus the KVK fallback below, returns one stable response shape, and includes the consultation number. Free for low volume — 100 checks a month, no card — and low cost above that. Register and you skip the Dutch-entity requirement entirely.
- Queue retries against VIES without a fallback. Simplest to write. Fine only if your flow can tolerate a "we'll get back to you" UX.
- Build the KVK integration yourself. Here is what doing it yourself involves: registering a Dutch entity, obtaining an API key, handling the legal-entity-only RSIN linkage and the company-vs-VAT-registry caveat — and the Dutch-entity requirement is a hard prerequisite, not a formality.
Step 5: use vatnode and skip the boilerplate
vatnode runs the full pipeline above on every Dutch request. If VIES NL is healthy, you get a VIES response with name, address, and a consultation number. If VIES NL is unavailable, vatnode queries KVK on the local block (for legal entities) and returns a KVK_NL-sourced response, with a VIES re-check queued. The KVK-access constraint — a Dutch entity plus an API key — is exactly the kind of thing that is cheaper to centralize than to reimplement.
const res = await fetch('https://api.vatnode.dev/v1/vat/NL001631457B01', {
headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
})
const data = await res.json()
// {
// "valid": true,
// "countryCode": "NL",
// "vatNumber": "001631457B01",
// "name": "Example B.V.",
// "address": "Voorbeeldstraat 1, 1011 AA Amsterdam",
// "source": "VIES", // or "KVK_NL"
// "consultationNumber": "WAPIAAAAX...",
// "checkedAt": "2026-08-14T08:30:00.000Z"
// }
NL001631457B01 is a publicly listed number (verify it yourself via VIES rather than trusting any single source). The source field is what lets your invoicing logic know which path served the response — VIES for the cross-border verdict, KVK_NL for the company-register fallback. The consultationNumber is the VIES-issued reference described in the VIES consultation number guide. It is issued only when your request includes a valid requester VAT number, and is documentary audit evidence — not a compliance guarantee or a safe harbour. Store it, because auditors may request proof of validation on intra-EU reverse-charge supplies under Council Directive 2006/112/EC.
if (data.source === 'VIES') {
// Use data.name and data.address on the invoice; store consultationNumber
} else if (data.source === 'KVK_NL') {
// KVK confirmed the company exists and is active — this is NOT a VAT verdict.
// Proceed if your flow allows, but flag the record for a VIES re-check.
}
Rate limiting, caching, and retries
VIES is not a high-throughput service, and KVK enforces its own quotas on top of requiring a key. A few patterns are worth committing to before you hit a problem:
- Positive cache: 24 hours. A successful VIES validation is good for at least a day in practice — VAT registrations rarely change intraday. Store the response (including
consultationNumber) and serve repeat lookups from cache. Many teams cache for 7 days; pick the window that matches your audit posture. - Negative cache: short and explicit. If VIES returned
invalid, cache that for 5–15 minutes — long enough to absorb retries from the same checkout session, short enough that a customer who just registered is not blocked for a day. If VIES returnedMS_UNAVAILABLE, cache for only 1–2 minutes; that is a transport signal, not a verdict. - Request deduplication. During a busy checkout flow, the same VAT ID can be looked up several times within seconds (form blur, server-side re-validation, webhook). Coalesce concurrent in-flight requests for the same
vatIdinto a single upstream call — RedisSETNXwith a short TTL works, as does a per-process in-memory promise map. - Retry with exponential backoff, capped. On
MS_UNAVAILABLE/SERVICE_UNAVAILABLE/ timeout, retry up to 2–3 times with backoff (e.g. 500ms, 2s, 5s), then fall over to KVK. Beyond that, queue for asynchronous re-check rather than blocking the request. - Bound the per-customer rate. A customer hammering your form will hammer VIES through you. Apply a per-customer or per-IP soft limit (e.g. 10 lookups/minute) before the upstream call.
The KVK key-and-entity requirement is another argument for centralizing: every avoided upstream call preserves a fallback budget that is harder to obtain than most. These are operational defaults, not legal requirements — they exist to keep your validation pipeline healthy under realistic SaaS traffic.
What to store in your database
For Dutch VAT IDs specifically, your validation log should include the cleaned VAT ID, the 9-digit local block, the source that answered, and — when only KVK answered — a flag telling a background job to re-check against VIES. Name the local-block column carefully: for a sole trader it is not an RSIN, so do not label it rsin unconditionally.
CREATE TABLE vat_checks_nl (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
vat_id text NOT NULL,
local_id text NOT NULL, -- the 9-digit block (RSIN for legal entities only)
rsin text, -- populated only when the entity is a legal person
valid boolean NOT NULL,
source text NOT NULL CHECK (source IN ('VIES', 'KVK_NL')),
consultation_no text,
entity_name text,
entity_address text,
requires_recheck boolean NOT NULL DEFAULT false,
checked_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON vat_checks_nl (vat_id);
CREATE INDEX ON vat_checks_nl (requires_recheck) WHERE requires_recheck;
Store the local block as text, never as an integer — a leading-zero number like 001631457 silently loses digits the moment it becomes a JavaScript Number. If source was KVK_NL, a background job picks up the row when VIES recovers, re-validates, and updates source, consultation_no, and requires_recheck. The broader Node.js VAT validation guide covers the cross-country schema vatnode customers use for this.
Common gotchas
- Customer pastes the private BSN-based number. The omzetbelastingnummer (
123456789B01) shares the…Bnnshape with the public btw-id but is for the tax office only and is invalid in VIES. If a lookup fails, ask the customer to confirm they sent the btw-id from their invoice, not their tax correspondence. - Expecting a checksum to validate. Post-2020 sole-trader btw-ids are randomized and fail the elfproef. Never reject a Dutch number on a local checksum.
- Treating the trailing digits as check digits.
B01/B02is a sequence number for multiple businesses of one holder, not a checksum. - Sole-trader btw-id has no RSIN. You cannot bridge a randomized sole-trader number to a KVK record via RSIN — the fallback only works for legal entities.
- Spaces and dots. Dutch numbers are often written with separators. Strip them in both modes before matching.
- VIES NL returns
MS_UNAVAILABLE. This is not "invalid". Treat it as a transient error, fall back to KVK or queue for retry, and never block checkout on it. The general pattern across all member states is covered in the VIES alternative with automatic fallback write-up. - KVK needs a Dutch entity. Its API key is not available to just anyone — factor that into any build-vs-buy decision on the fallback path.
Validate NL VAT numbers without standing up KVK access yourself
vatnode handles VIES NL plus the KVK fallback in one call, returns a stable response shape with a source field, and includes the VIES consultation number for your audit trail. Free plan, 100 requests/month.
Get a free API key · API reference · Netherlands VAT API reference