Validate Dutch VAT Numbers (BTW-id) in Node.js
14 Aug 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 entirely on VIES – it is the only source that answers whether a Dutch number is actually VAT-registered. The KVK company register is often reached for as a second opinion; it cannot serve as one, and this guide covers why. 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, and – as the next section explains – no Dutch national source can answer VAT registration in VIES’s place, VIES availability matters more here than almost anywhere else. When the NL node is down, you get the outage: there is no Dutch validity fallback to substitute a verdict.
Step 4: KVK enrichment (not a validity fallback)
It is tempting to reach for KVK (Kamer van Koophandel), the national company register, as a Dutch analogue to France’s SIRENE the moment VIES NL goes down. Do not. KVK cannot answer the question VIES answers, so it is not a fallback for validity – it is enrichment layered on top of a VIES verdict. 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. That is useful data – but it is company data, not VAT status.
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 (below-threshold, exempt, or simply not registered for intra-EU supplies). Presence in KVK is therefore not an answer to ‘is this VAT number valid’ – deriving
validfrom it would produce false positives. - 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 enrichment rather than standing up KVK access yourself.
So KVK’s role is narrow but real: when VIES returns a valid answer, KVK can add the company’s registration date, legal form, and registry details that VIES itself does not return. VIES stays the validity source of truth; KVK decorates the record. And the corollary is the honest part: when VIES NL is unavailable, there is no Dutch validity fallback. KVK cannot fill in for it, so you get the outage – the same shape as Belgium, where the CBE company register is enrichment-only for exactly the same reason.
async function validateDutchVat(vatId: string) {
const cleaned = normaliseDutchVatId(vatId)
if (!cleaned) {
return { valid: false, error: 'INVALID_FORMAT' }
}
let vies
try {
vies = await callVies(cleaned)
} catch (e) {
// VIES NL is down. There is NO Dutch validity fallback — KVK cannot tell
// you whether a number is VAT-registered. Surface the outage and queue a
// re-check; do not invent a verdict from the company register.
return { valid: null, error: 'VIES_UNAVAILABLE', requiresRecheck: true }
}
// VIES answered — it is the source of truth for `valid`.
const result = {
valid: vies.valid,
name: vies.name,
address: vies.address,
source: 'VIES',
}
// Optional enrichment: only for a VALID legal-entity number, and never a verdict.
// Post-2020 sole-trader btw-ids have no derivable RSIN, so KVK cannot be reached
// for them anyway. This only ever adds fields; it never changes `valid` or `source`.
if (result.valid) {
const rsin = cleaned.slice(2, 11) // 9-digit block — RSIN for legal entities only
const kvk = await callKvk(rsin).catch(() => null)
if (kvk) {
Object.assign(result, {
registrationDate: kvk.registrationDate,
legalForm: kvk.legalForm,
kvkNumber: kvk.kvkNumber,
})
}
}
return result
}
There are three ways to run the NL pipeline, fastest first:
- Use vatnode. It runs VIES NL, returns one stable response shape, and includes the consultation number. When VIES NL is down it tells you so honestly – no fabricated verdict. The Netherlands is VIES-only in vatnode today: you get the trader name and address VIES returns, not KVK company metadata on top. Free for low volume – 100 checks a month, no card – and low cost above that.
- Call VIES directly and queue retries on unavailability. Simplest to write. Fine only if your flow can tolerate a ‘we’ll get back to you’ UX during an NL-node outage.
- Build the KVK enrichment yourself. Here is what doing it yourself involves: registering a Dutch entity, obtaining an API key, and handling the legal-entity-only RSIN linkage – all to add company metadata on top of VIES, not to replace it. The Dutch-entity requirement is a hard prerequisite, not a formality.
Step 5: use vatnode and skip the boilerplate
vatnode runs the VIES half of the pipeline above on every Dutch request. If VIES NL is healthy, you get a VIES response with the trader name, address and a consultation number. The source is always VIES, because VIES is what decides validity. If VIES NL is unavailable, vatnode surfaces that as an error rather than guessing from the company register – there is no Dutch validity fallback to substitute.
The Netherlands is VIES-only in vatnode today: the KVK enrichment described in Step 4 is not part of what a Dutch check returns, so registration date, legal form and SBI industry come back empty for NL. Per-country coverage is listed at /docs/coverage. If you need KVK company metadata, Step 4 above is the build-it-yourself path – including the Dutch-entity prerequisite for an API key.
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",
// "vatId": "NL001631457B01",
// "companyName": "Example B.V.",
// "companyAddress": "Voorbeeldstraat 1, 1011 AA Amsterdam",
// "source": "VIES", // always VIES — it decides validity
// "companyForm": null, // NL is VIES-only – no KVK enrichment
// "consultationNumber": "WAPIAAAAX...",
// "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",
// "verifiedAt": "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 is VIES because VIES is what decides validity. The registry fields – companyForm, companyRegistrationDate, industryDescription – are part of the response shape for every country, and for NL they are null. 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.valid) {
// VIES confirmed the number. Use data.name and data.address on the invoice
// and store consultationNumber.
} else if (data.error?.code === 'VIES_UNAVAILABLE') {
// The NL node is down and there is no Dutch validity fallback. Do NOT proceed
// as if the number were valid — queue an asynchronous re-check instead.
}
Rate limiting, caching, and retries
VIES is not a high-throughput service, and if you build the KVK side yourself it enforces its own quotas on top of requiring a paid key – run it only on valid answers, as enrichment. 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, then queue. On
MS_UNAVAILABLE/SERVICE_UNAVAILABLE/ timeout, retry up to 2–3 times with backoff (e.g. 500ms, 2s, 5s). Because there is no Dutch validity fallback, once retries are exhausted you queue for asynchronous re-check rather than blocking the request – you cannot substitute a verdict from KVK. - 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.
If you do stand up KVK access yourself, cache hard on the enrichment side too: the key is metered and harder to obtain than most, so every avoided upstream call is budget you keep. 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, whether VIES answered, and – when VIES was unavailable – a flag telling a background job to re-check. Validity always comes from VIES, so there is no second source value to record; KVK data, when present, is enrichment stored alongside. 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, -- NULL when VIES was unavailable (no verdict yet)
vies_status text NOT NULL, -- 'OK' | 'MS_UNAVAILABLE' | 'SERVICE_UNAVAILABLE' | 'TIMEOUT'
consultation_no text, -- VIES-issued; never from KVK
entity_name text,
entity_address text,
kvk_number text, -- KVK enrichment, only on a valid legal entity
company_form text, -- KVK enrichment
requires_recheck boolean NOT NULL DEFAULT false, -- set when VIES was unavailable
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. When vies_status was not OK, the row has no verdict (valid stays NULL); a background job picks it up when VIES recovers, re-validates, and updates valid, vies_status, 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 KVK enrichment 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 and queue for retry – there is no Dutch validity fallback, so never substitute a KVK result and never block checkout on it. The general pattern across all member states is covered in the VIES alternative with automatic fallback write-up. - Treating KVK as a fallback. KVK confirms a company exists; it does not confirm VAT registration. It is enrichment on top of a valid VIES answer, never a substitute when VIES is down.
- KVK needs a Dutch entity. Its API key is not available to just anyone – factor that in before you plan a feature around KVK company metadata.
Validate NL VAT numbers without owning the VIES NL retry logic
vatnode normalizes the input, runs a requester-qualified VIES call to the Dutch node, and returns a stable response with a source field and the VIES consultation number for your audit trail. The Netherlands is VIES-only in vatnode today – name and address, no KVK enrichment. Free plan, 100 requests/month.
Get a free API key · API reference · Netherlands VAT API reference