Guide · Node.js · SDK · REST API

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.

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)
⚠️A single catch-all regex will silently reject thousands of valid VAT numbers. French numbers start with two alphanumeric characters (not just digits). Greek numbers use the 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:

CountryPrefixFormat
GermanyDE9 digits
FranceFR2 alphanumeric + 9 digits
PolandPL10 digits
IrelandIE7 digits + 1–2 letters
GreeceEL9 digits (prefix is EL, not GR)
SpainESletter/digit + 7 digits + letter/digit
NetherlandsNL9 digits + B + 2 digits
ItalyIT11 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.

60+
Countries
EU + non-EU
<50ms
Response time
format check only
100/day
Free tier
no credit card

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}`);
}
Always separate format/checksum validation (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"
  }
}
FieldTypeDescription
validbooleanFormat and checksum are correct
normalizedstringUppercased, spaces/hyphens removed, prefix stripped
countryCodestring2-letter code used for validation
countryNamestringFull country name
isEUbooleanWhether the country is an EU member state
vies.checkedbooleantrue if VIES was reachable and queried
vies.validbooleanWhether the number is actively registered in VIES
vies.namestring | nullRegistered business name from VIES
vies.addressstring | nullRegistered 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

Do not rely on a single regex for all EU countries
Do not treat format-valid as registered
Use per-country format + checksum validation
Query VIES for active EU B2B transactions
Handle VIES downtime gracefully — do not reject on timeout
Separate format errors from registration status in your UX

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_KEY is 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.