How to Validate EU VAT Numbers in Node.js
EU VAT validation is harder than it looks. Here's why regex won't save you — and how to get it right with a single API call.
In this guide
1. The problem with EU VAT validation
At first glance, validating a VAT number in a Node.js app seems trivial — just check the format, right? But EU VAT numbers span 27 member states, each with its own structure, length, character set, and checksum algorithm. And format validation is only half the story: a number that looks correct might still be unregistered, suspended, or belong to a dissolved company.
If your app invoices B2B customers in Europe, incorrect VAT handling carries real tax liability. This guide walks through the failure modes and the practical solution.
2. The naive approach: regex (and why it fails)
The first thing most developers try is a simple regex. Something like this:
// ❌ DO NOT USE — this will reject valid VAT numbers const EU_VAT_REGEX = /^(AT|BE|BG|HR|CY|CZ|DK|EE|FI|FR|DE|EL|HU|IE|IT|LV|LT|LU|MT|NL|PL|PT|RO|SK|SI|ES|SE)[0-9A-Z]{8,12}$/; function validateVat(vat) { return EU_VAT_REGEX.test(vat.toUpperCase().replace(/\s/g, '')); } validateVat('DE123456788'); // true ✓ (but might not exist) validateVat('FR12345678801'); // false ✗ (actually valid — FR allows letters!) validateVat('EL123456788'); // false ✗ (Greece uses EL, not GR) validateVat('IE1234567FA'); // false ✗ (Ireland's format: digit + 5 digits + 2 letters)
EL prefix, not GR. Irish numbers end with letters. Spain uses a mix of letters and digits in a specific pattern.To handle all EU countries correctly, you'd need 27 separate regexes — plus checksum verification logic for many of them (Germany, Poland, Italy, Spain, etc. all use country-specific check digit algorithms).
3. Why VAT validation is genuinely hard
Every country has a different format
Here's a sample of just the EU member states:
| Country | Prefix | Format |
|---|---|---|
| Germany | DE | 9 digits |
| France | FR | 2 alphanumeric + 9 digits |
| Poland | PL | 10 digits |
| Ireland | IE | 7 digits + 1–2 letters |
| Greece | EL | 9 digits (prefix is EL, not GR) |
| Spain | ES | letter/digit + 7 digits + letter/digit |
| Netherlands | NL | 9 digits + B + 2 digits |
| Italy | IT | 11 digits |
Many countries require checksum verification
Germany, Poland, Netherlands, Italy, Spain, and others use modulus-based check digit algorithms. A number that matches the regex may still fail the checksum — which means it was never issued to anyone. Implementing these correctly requires country-specific arithmetic, not just pattern matching.
Format valid ≠ actually registered
Even a perfectly formatted, checksum-valid number might not belong to an active business. To confirm a company is currently VAT-registered in the EU, you need to query the VIES system (VAT Information Exchange System) — the official EU registry.
VIES has downtime and inconsistent responses
VIES is a federated system — each EU country runs its own node. Some national nodes go offline for maintenance, return timeouts, or temporarily return MS_UNAVAILABLE. Your code needs to handle partial failures gracefully: a VIES timeout does not mean the VAT number is invalid.
4. The right solution: one API call
Instead of maintaining 27 country-specific validators plus VIES integration, use the IsValid VAT API. A single GET request handles format validation, checksum verification, and optional VIES lookup — for 60+ countries.
Get your free API key at isvalid.dev. The free tier includes 100 calls per day — enough for most development and low-volume production use.
Full parameter reference and response schema: VAT Validation API docs →
5. Node.js code example
Using the IsValid SDK or the native fetch API.
import { createClient } from '@isvalid-dev/sdk'; const iv = createClient({ apiKey: process.env.ISVALID_API_KEY }); // ── Example usage ──────────────────────────────────────────────────────────── const result = await iv.vat('DE123456788', { checkVies: true }); if (!result.valid) { console.log('Invalid VAT format or checksum'); } else if (result.vies?.checked && !result.vies.valid) { console.log('Format OK, but not found in VIES — may be suspended or not yet registered'); } else if (result.vies?.checked && result.vies.valid) { console.log(`Active EU VAT: ${result.vies.name}`); } else { console.log(`Format valid — ${result.countryName}`); }
result.valid) from VIES registration status (result.vies.valid). A VIES failure does not mean the number is wrong — the national node might just be offline.6. cURL example
Format + checksum validation only (fast, no VIES call):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=DE123456788"
With VIES registration check (slower — queries EU servers):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=DE123456788&checkVies=true"
With explicit country code (useful when the number has no prefix):
curl -H "Authorization: Bearer YOUR_API_KEY" \ "https://api.isvalid.dev/v0/vat?value=123456788&countryCode=DE"
7. Understanding the response
Response for a valid German VAT number with VIES check enabled:
{ "valid": true, "normalized": "123456788", "countryCode": "DE", "countryName": "Germany", "isEU": true, "vies": { "checked": true, "valid": true, "name": "EXAMPLE GMBH", "address": "MUSTERSTRASSE 1\n10115 BERLIN" } }
| Field | Type | Description |
|---|---|---|
| valid | boolean | Format and checksum are correct |
| normalized | string | Uppercased, spaces/hyphens removed, prefix stripped |
| countryCode | string | 2-letter code used for validation |
| countryName | string | Full country name |
| isEU | boolean | Whether the country is an EU member state |
| vies.checked | boolean | true if VIES was reachable and queried |
| vies.valid | boolean | Whether the number is actively registered in VIES |
| vies.name | string | null | Registered business name from VIES |
| vies.address | string | null | Registered business address from VIES |
When checkVies is omitted or false, the vies field is not included in the response. When VIES is unreachable, vies.checked is false and vies.reason is "unavailable".
8. Edge cases to handle
Suspended VAT numbers
A company can have its VAT registration suspended by the tax authority — the number remains syntactically valid and passes checksum, but VIES returns valid: false. Never zero-rate invoices based on format alone; always run a VIES check for B2B EU sales.
// Handle suspended / deregistered VAT if (result.valid && result.vies?.checked && !result.vies.valid) { // Format OK, but not in VIES — reject or flag for manual review throw new Error('VAT number is not currently registered'); }
VIES downtime (national node unavailable)
When a national node is offline, the API returns vies.checked: false with vies.reason: "unavailable". Do not reject the transaction in this case — treat it as "format valid, VIES inconclusive" and consider retrying later or allowing the transaction with a note.
if (result.vies && !result.vies.checked) { if (result.vies.reason === 'unavailable') { // VIES is temporarily down — do not reject, log and retry later logger.warn('VIES unavailable for', vatNumber); return { valid: result.valid, viesStatus: 'unavailable' }; } if (result.vies.reason === 'not_eu') { // Non-EU country — no VIES check possible, format-only validation return { valid: result.valid, viesStatus: 'not_applicable' }; } }
Input normalisation
Users enter VAT numbers in many formats: DE 123 456 788, de123456788, DE-123.456.788. The API strips spaces, hyphens, and dots automatically and uppercases the input. Pass the raw user input — no need to pre-process it.
Network failures in your code
Always wrap the API call in a try/catch. A network timeout should not cause your checkout to crash — decide upfront whether to fail open or closed on API unavailability.
async function validateVatSafe(vatNumber, options = {}) { try { return await validateVat(vatNumber, options); } catch (err) { // Log and decide: fail open (allow) or fail closed (reject) logger.error('VAT validation failed:', err.message); return { valid: null, error: 'validation_unavailable' }; } }
Summary
Node.js integration notes
When handling EU VAT Number in a TypeScript codebase, define a branded type to prevent accidental mixing of financial identifier strings at compile time:type VatNumber = string & { readonly __brand: 'VatNumber' }. The IsValid SDK ships with full TypeScript definitions covering all response fields, including country-specific and instrument-specific data, so your editor provides autocomplete on the parsed result without manual type declarations.
In financial data pipelines — payment processors, reconciliation engines, or KYC workflows — EU VAT Number validation sits at the ingestion boundary. Pair the IsValid SDK with decimal.js orbig.js for any monetary amounts tied to the identifier, and usepino for structured logging that attaches the validation result to the transaction reference in every log line, making audit trails straightforward.
Express.js and Fastify middleware
Centralise EU VAT Number validation in a request middleware rather than repeating it in every route handler. The middleware calls the IsValid API, attaches the parsed result toreq.validated, and callsnext() on success. Layer in a Redis cache keyed by the normalised identifier with a 24-hour TTL to avoid redundant API calls for the same value across multiple requests in the same session.
Error handling should distinguish between a 422 response from IsValid (the EU VAT Number is structurally invalid — return this to the caller immediately) and 5xx or network errors (transient failures — retry once after a short delay before surfacing a service-unavailable error). Never swallow validation failures silently; they indicate bad data that could propagate into financial records downstream.
- Assert
process.env.ISVALID_API_KEYis present at server startup, not lazily at first request - Use
Promise.allSettled()for batch validation — it collects all results without aborting on the first failure - Mock the IsValid client with
jest.mock()in unit tests; keep CI pipelines free of real API calls - Store the full parsed API response alongside the raw EU VAT Number in your database — country code, institution data, and check-digit status are useful for downstream logic
When making HTTP calls to the IsValid API directly (without the SDK), the choice between fetch and axios is largely a matter of preference. The native fetch API is available in Node.js 18+ without any additional dependency and is sufficient for simple request/response flows. axios adds automatic JSON parsing, request/response interceptors, and a cleaner timeout API (axios.create({ timeout: 5000 })), which makes it easier to centralise the Authorization header and retry logic in one place. For high-throughput services that make many concurrent API calls, consider undici — the HTTP client underlying Node.js fetch — used directly for its connection pooling and lower overhead.
See also
Try VAT validation instantly
Free tier includes 100 API calls per day. No credit card required. Works for 60+ countries including all 27 EU member states.