Home / Blog / EU VAT Number Validation: The Complete Developer Guide (2026)

Compliance15 min readby Alberto García

EU VAT Number Validation: The Complete Developer Guide (2026)

VIES is SOAP-based, unreliable, and has no caching. This guide explains how EU VAT validation works end-to-end, how to handle downtime gracefully, and includes copy-paste code examples in four languages.


If you sell SaaS, digital services, or goods to EU businesses, validating your customers' VAT numbers is a legal requirement — not a nice-to-have. Get it wrong and your company is personally liable for the full VAT amount on every zero-rated transaction. This guide walks through exactly how EU VAT validation works end-to-end: what a VAT number is, how the EU VIES system processes validation requests, how to handle its frequent downtime, how to store results for audit compliance, and how to implement the full flow correctly in Node.js, Python, and PHP.

Note

Official source: VAT number formats and validation rules are defined by the EU Council Directive 2006/112/EC (the VAT Directive). Real-time validation is provided by VIES — VAT Information Exchange System, operated by the European Commission.

What is an EU VAT Number?

An EU VAT number (Value Added Tax identification number) is a unique identifier assigned to businesses registered for VAT in a European Union member state. It serves as proof that a business is registered in the EU tax system and is legally entitled to receive zero-rate treatment on intra-community B2B supplies. Without a valid VAT number, you cannot apply zero-rate — you must charge VAT at the applicable rate.

Each number starts with a two-letter country prefix followed by up to 12 characters. The exact format is country-specific and in some cases deceptively complex. Germany uses DE + 9 digits. France uses FR + 2 alphanumeric characters + 9 digits. Spain uses ES + a letter or digit + 7 digits + a letter or digit — making it one of the harder formats to validate by regex alone. The Netherlands uses NL + 9 digits + B + 2 digits (the B is mandatory, not a separator). Greece uses EL as its VAT prefix, not GR — a common gotcha that causes silent validation failures when developers use the ISO country code by mistake.

CountryPrefixFormatExampleNotes
GermanyDEDE + 9 digitsDE123456789Most straightforward format
FranceFRFR + 2 chars + 9 digitsFR12345678901First 2 chars can be alpha or digit
SpainESES + char + 7 digits + charESX1234567XFirst and last can be letter or digit
NetherlandsNLNL + 9 digits + B + 2 digitsNL123456789B01B is mandatory, not optional
ItalyITIT + 11 digitsIT12345678901All numeric after prefix
PolandPLPL + 10 digitsPL123456789010 digits, not 9
BelgiumBEBE + 10 digitsBE0123456789Leading zero is valid
SwedenSESE + 12 digitsSE12345678900112 digits, ends in 01
GreeceELEL + 9 digitsEL123456789Uses EL not GR — common mistake
PortugalPTPT + 9 digitsPT1234567899 digits only

A valid-looking format does not guarantee the business is actually registered. A number can pass every regex test but still fail VIES lookup if the business has deregistered, was entered with a transposition error, or was never registered in the first place. This is why you need two layers of validation: format first (local, zero-latency, saves API quota), then VIES lookup (live, authoritative). The TaxID API handles both steps automatically — format errors return immediately without consuming your monthly quota.

For a complete list of all 27 EU member state formats, see the country-specific validation pages: Austria, Belgium, Bulgaria, Croatia, Cyprus, Czech Republic, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, Ireland, Italy, Latvia, Lithuania, Luxembourg, Malta, Netherlands, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden. Each page includes the regex pattern, a valid example, and code examples for that country's specific format.

The EU Legal Framework: Why Validation Is Mandatory

Under EU Council Directive 2006/112/EC (the VAT Directive), intra-community B2B supplies can be zero-rated — the seller does not charge VAT, and the buyer accounts for it via the reverse charge mechanism in their own country. This zero-rate only applies when the buyer provides a valid VAT registration number and the supplier has taken reasonable steps to verify it. 'Reasonable steps' means VIES validation, not just accepting whatever string the customer typed.

If you apply zero-rate treatment without verifying the customer's registration and an audit later reveals the number was invalid, your company — not the customer — becomes liable for the full VAT amount on that transaction, plus penalties and interest. EU tax authorities run systematic cross-checks between VAT returns and VIES data. The bigger your zero-rated invoice volume, the higher your audit risk. This is not a theoretical concern — tax authorities in Germany, France, and the Netherlands actively investigate discrepancies in intra-community supplies.

For marketplace platforms, DAC7 (EU Directive 2021/514) adds a separate layer of obligation. Platforms that facilitate transactions between sellers and buyers must collect and validate seller VAT numbers for any seller earning above €2,000 or completing 30+ transactions per year. Annual DAC7 reporting to national tax authorities is mandatory. Failure to comply carries fines of up to 1% of affected transaction volume in some member states.

Warning

Applying zero-rate VAT without VIES verification makes your company — not the buyer — liable for the full VAT amount. EU tax authorities run automated cross-checks. Validate before every zero-rated invoice.

How VIES Works: The Technical Architecture

VIES (VAT Information Exchange System) is the EU's official gateway for cross-border VAT number verification, operated by the European Commission's Directorate-General for Taxation and Customs Union (DG TAXUD). It is not a database — it is a routing layer. Each of the 27 member states maintains its own national VAT registration database (Germany's is the BZST, France's is the DGFiP, and so on). When you submit a validation request to VIES, it routes your query to the appropriate national system, waits for a response, and returns the result to you.

The VIES web service is implemented as a SOAP/XML API — the same protocol dominant in enterprise software in the early 2000s. This is not an oversight; it reflects the age of the infrastructure. Calling VIES directly from a modern Node.js or Python application requires a SOAP client library, XML parsing, and tolerance for a response format that looks nothing like a REST API. Response times typically range from 200ms to 2 seconds depending on the target member state and server load. Some member states have VIES endpoints that regularly take 1.5 seconds even when fully operational.

VIES also has a well-documented reliability problem. Individual member state systems go offline for scheduled maintenance without advance notice to third parties. The central VIES gateway itself has scheduled downtime windows. Some member states (particularly smaller ones) have historically poor VIES uptime. Any production system that calls VIES directly must implement explicit downtime handling. See VIES Downtime: How to Build a Resilient VAT Validation Flow for the full resilience strategy.

Note

The TaxID API wraps VIES in a REST/JSON interface with Redis caching, explicit service_unavailable status codes, and sub-10ms responses for cached results. You get the authoritative VIES answer without writing a SOAP client.

Step 1: Format Validation Before the VIES Call

Always validate the format of a VAT number locally before making a network call to VIES. There are two reasons: first, a VIES call for a malformed number wastes your API quota (both your own monthly limit and the underlying VIES quota that affects all users). Second, VIES returns SOAP fault errors for malformed inputs that are harder to parse than a clean 422 response. Format validation is instant, free, and catches most user input errors before they become API calls.

typescriptvat-format.ts
// Minimal format validators for the highest-volume EU markets.
// For all 27 member states, use the TaxID API — it validates format
// before every VIES call with no quota consumption on rejection.
const VAT_FORMATS: Record<string, RegExp> = {
  AT: /^ATU[0-9]{8}$/,
  BE: /^BE[0-1][0-9]{9}$/,
  BG: /^BG[0-9]{9,10}$/,
  CY: /^CY[0-9]{8}[A-Z]$/,
  CZ: /^CZ[0-9]{8,10}$/,
  DE: /^DE[0-9]{9}$/,
  DK: /^DK[0-9]{8}$/,
  EE: /^EE[0-9]{9}$/,
  EL: /^EL[0-9]{9}$/,   // Greece uses EL, not GR
  ES: /^ES[A-Z0-9][0-9]{7}[A-Z0-9]$/,
  FI: /^FI[0-9]{8}$/,
  FR: /^FR[0-9A-Z]{2}[0-9]{9}$/,
  HR: /^HR[0-9]{11}$/,
  HU: /^HU[0-9]{8}$/,
  IE: /^IE[0-9][A-Z0-9\+\*][0-9]{5}[A-Z]{1,2}$/,
  IT: /^IT[0-9]{11}$/,
  LT: /^LT([0-9]{9}|[0-9]{12})$/,
  LU: /^LU[0-9]{8}$/,
  LV: /^LV[0-9]{11}$/,
  MT: /^MT[0-9]{8}$/,
  NL: /^NL[0-9]{9}B[0-9]{2}$/,
  PL: /^PL[0-9]{10}$/,
  PT: /^PT[0-9]{9}$/,
  RO: /^RO[0-9]{2,10}$/,
  SE: /^SE[0-9]{12}$/,
  SI: /^SI[0-9]{8}$/,
  SK: /^SK[0-9]{10}$/,
};

export function isValidVatFormat(vat: string): boolean {
  const code = vat.slice(0, 2).toUpperCase();
  return VAT_FORMATS[code]?.test(vat.toUpperCase()) ?? false;
}

Note the Greece entry: EL, not GR. This is the single most common format mistake developers make. Greece's ISO 3166-1 alpha-2 code is GR, but in the VIES system it is EL (from the Greek name Ελλάδα). If you submit GR12345678 to VIES, it will return a country_not_found error. Always normalise to the VIES country code before validation. The TaxID API accepts both GR and EL and normalises internally.

Step 2: Making the API Request

The simplest way to call VIES from a modern application is through the TaxID REST API. A single GET request with a Bearer token returns a JSON response in milliseconds for cached results and under 2 seconds for fresh VIES lookups. No SOAP client, no XML parsing, no retry logic for timeouts — the API handles all of that.

bash
# Validate a German VAT number
curl https://taxid.dev/api/v1/validate/DE/DE123456789 \
  -H "Authorization: Bearer YOUR_API_KEY"

# Validate a French TVA number
curl https://taxid.dev/api/v1/validate/FR/FR12345678901 \
  -H "Authorization: Bearer YOUR_API_KEY"

# Get your API key at https://taxid.dev/signup (free, 100 req/month)

Understanding Every Response Field

Each field in the response carries specific meaning. Misreading even one field — particularly the difference between valid: false due to an invalid number versus valid: false due to VIES being unavailable — causes silent compliance failures. Here is what every field means and how to use it:

FieldTypeMeaning
validbooleantrue only if VIES confirmed the number is actively registered. false for invalid, inactive, or unavailable.
statusstringactive | inactive | format_invalid | service_unavailable. Always check this, not just valid.
vatstringThe normalised full VAT number as submitted (e.g. DE123456789).
country_codestringTwo-letter VIES country code. EL for Greece, not GR.
company_namestring | nullBusiness name from VIES. null for some member states that do not share this field.
addressstring | nullRegistered address from VIES. null for member states that redact address data.
cachedbooleantrue if this response was served from Redis cache (sub-10ms). false if a live VIES call was made.
request_idstringUnique request identifier. Include in support tickets and audit logs.

The status field is the most important. If status is service_unavailable, the number may be perfectly valid — VIES simply could not be reached at the time of the request. Treating service_unavailable the same as inactive will block legitimate customers during EU maintenance windows. If status is inactive, the number exists in VIES but the business has deregistered. If status is format_invalid, the number failed local format validation before reaching VIES — it was never submitted to VIES at all, and no quota was consumed.

json
// Active, live VIES response
{
  "valid": true,
  "status": "active",
  "vat": "DE123456789",
  "country_code": "DE",
  "company_name": "Example GmbH",
  "address": "Musterstraße 1, 10115 Berlin",
  "cached": false,
  "request_id": "req_01j9kx..."
}

// VIES unavailable — NOT the same as invalid
{
  "valid": false,
  "status": "service_unavailable",
  "vat": "DE123456789",
  "country_code": "DE",
  "company_name": null,
  "address": null,
  "cached": false,
  "request_id": "req_01j9ky..."
}

Handling VIES Downtime

VIES goes offline regularly — scheduled maintenance, emergency patches, and individual member state outages all contribute to a real-world availability below 100%. Any checkout or onboarding flow that hard-fails on service_unavailable will block legitimate customers with valid VAT numbers. The full resilience strategy — including allow-and-re-validate patterns, circuit breakers, and Express.js middleware — is covered in VIES Downtime: How to Build a Resilient VAT Validation Flow. The short version: check for status === 'service_unavailable' explicitly and allow the transaction to proceed while queuing the number for background re-validation.

typescriptcheckout-vat.ts
type VatOutcome = 'valid' | 'invalid' | 'unavailable';

async function validateAtCheckout(
  vatNumber: string,
  customerId: string
): Promise<VatOutcome> {
  const country = vatNumber.slice(0, 2).toUpperCase();

  const res = await fetch(
    `https://taxid.dev/api/v1/validate/${country}/${vatNumber}`,
    {
      headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
      signal: AbortSignal.timeout(5000),
    }
  );
  const data = await res.json();

  // Never treat service_unavailable as invalid
  if (data.status === 'service_unavailable') {
    await queueRevalidation(customerId, vatNumber);
    return 'unavailable';
  }

  if (data.valid) {
    await saveValidatedCustomer(customerId, {
      vatNumber,
      companyName: data.company_name,
      address: data.address,
      validatedAt: new Date(),
    });
    return 'valid';
  }

  return 'invalid';
}

Storing Validation Results for Audit Compliance

VIES validation is a point-in-time check. A number valid today may be deregistered next month. For audit purposes, you need to store not just whether a number was valid, but when you checked it, what VIES returned, and the full request context. EU tax authorities may ask for evidence that you verified a customer's registration before applying zero-rate treatment — a database record with a timestamp and request_id satisfies this requirement.

At minimum, store: the full VAT number as normalised by the API, the status returned (active, inactive, service_unavailable), the timestamp of the validation, the TaxID request_id for traceability, and the company_name and address if returned. If VIES returned service_unavailable, store the eventual re-validation result separately with its own timestamp. Never overwrite the original validation record — you need the full audit trail.

From a GDPR perspective, VAT numbers are business identifiers, not personal data — they are not subject to GDPR's right to erasure in the way personal data is. However, if you are storing company_name and address, these could be personal data if the business is a sole trader rather than a registered company. Consult your privacy policy. The safest approach is to treat them as potentially personal data, set a retention period aligned with your tax obligations (typically 7-10 years in most EU jurisdictions), and purge on request if the data subject is a sole trader.

typescriptvat-store.ts
interface VatValidationRecord {
  id: string;
  customerId: string;
  vatNumber: string;
  countryCode: string;
  status: 'active' | 'inactive' | 'format_invalid' | 'service_unavailable';
  isValid: boolean;
  companyName: string | null;
  address: string | null;
  requestId: string;
  checkedAt: Date;
  source: 'checkout' | 'revalidation' | 'manual';
}

// Store immediately after every API call — even on service_unavailable
async function recordValidation(
  customerId: string,
  apiResponse: Record<string, unknown>,
  source: VatValidationRecord['source']
): Promise<void> {
  await db.vatValidation.create({
    data: {
      customerId,
      vatNumber: apiResponse.vat as string,
      countryCode: apiResponse.country_code as string,
      status: apiResponse.status as string,
      isValid: apiResponse.valid as boolean,
      companyName: apiResponse.company_name as string | null,
      address: apiResponse.address as string | null,
      requestId: apiResponse.request_id as string,
      checkedAt: new Date(),
      source,
    },
  });
}

Re-validating Periodically

VAT registration status changes. A business that was validly registered at signup may deregister six months later. For SaaS subscriptions, this matters because you may be issuing zero-rate invoices to a customer who is no longer registered — creating a compliance gap in your VAT returns. The risk depends on your volume and the EU member states you serve, but it is real enough that any SaaS with significant B2B EU revenue should implement periodic re-validation.

The recommended re-validation schedule depends on your business model. For monthly subscriptions, re-validate at the start of each billing period before issuing the invoice. For annual plans, re-validate quarterly. For high-volume transaction platforms, re-validate every 30 days. Always re-validate immediately when a customer updates their billing details. Set up an alert if re-validation returns inactive for a customer who was previously active — you need to switch them to B2C billing and issue a corrected invoice.

Tip

Re-validation is cheaper than a tax audit. At TaxID's Starter plan ($19/month, 10,000 validations), re-validating 5,000 B2B customers monthly costs less than €0.002 per customer per check. The cost of issuing a single uncorrected zero-rate invoice to a deregistered business in a German tax audit is orders of magnitude higher.

B2B vs B2C — The Tax Treatment Decision

The core business logic that drives why you need VAT validation at all is the B2B/B2C distinction. For EU businesses selling digital services or goods across EU borders, the treatment is fundamentally different based on whether the buyer is a registered business or a consumer. Validation determines which category applies.

For SaaS billing systems, the practical implementation is: collect the VAT number at signup, validate via API, tag the customer as b2b or b2c in your billing engine (Stripe, Paddle, Lago, etc.), and apply the correct tax treatment to all subsequent invoices. For Stripe specifically, see Validate EU VAT numbers in Stripe Checkout for the full implementation — it covers applying the zero-rate exemption server-side before the payment intent is created, so tax is never charged to a verified B2B customer.

Full Code Examples

Node.js / TypeScript

See EU VAT Validation in Node.js for the full tutorial including caching strategy and Jest test patterns. The minimal implementation:

typescriptvat-service.ts
export interface VatResult {
  isValid: boolean;
  isUnavailable: boolean;
  companyName: string | null;
  address: string | null;
  status: string;
  cached: boolean;
  requestId: string;
}

export async function validateEUVat(vat: string): Promise<VatResult> {
  const country = vat.slice(0, 2).toUpperCase();

  const response = await fetch(
    `https://taxid.dev/api/v1/validate/${country}/${vat}`,
    {
      headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
      signal: AbortSignal.timeout(5000),
    }
  );

  if (!response.ok) throw new Error(`VAT API ${response.status}: ${response.statusText}`);

  const data = await response.json();
  return {
    isValid: data.valid === true,
    isUnavailable: data.status === 'service_unavailable',
    companyName: data.company_name ?? null,
    address: data.address ?? null,
    status: data.status,
    cached: data.cached,
    requestId: data.request_id,
  };
}

Python

pythonvalidate_vat.py
import os
import requests
from dataclasses import dataclass
from typing import Optional

@dataclass
class VatResult:
    is_valid: bool
    is_unavailable: bool
    company_name: Optional[str]
    address: Optional[str]
    status: str
    cached: bool
    request_id: str

def validate_eu_vat(vat: str) -> VatResult:
    country = vat[:2].upper()
    r = requests.get(
        f"https://taxid.dev/api/v1/validate/{country}/{vat}",
        headers={"Authorization": f"Bearer {os.environ['TAXID_API_KEY']}"},
        timeout=5,
    )
    r.raise_for_status()
    d = r.json()
    return VatResult(
        is_valid=d["valid"] is True,
        is_unavailable=d["status"] == "service_unavailable",
        company_name=d.get("company_name"),
        address=d.get("address"),
        status=d["status"],
        cached=d["cached"],
        request_id=d["request_id"],
    )

PHP

phpValidateVat.php
function validateEuVat(string $vat): array {
    $country = strtoupper(substr($vat, 0, 2));
    $ch = curl_init("https://taxid.dev/api/v1/validate/{$country}/{$vat}");
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('TAXID_API_KEY')],
        CURLOPT_TIMEOUT => 5,
    ]);
    $body = curl_exec($ch);
    $http  = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($http !== 200) throw new \RuntimeException("VAT API error: $http");
    $d = json_decode($body, true);
    return [
        'is_valid'       => $d['valid'] === true,
        'is_unavailable' => $d['status'] === 'service_unavailable',
        'company_name'   => $d['company_name'] ?? null,
        'address'        => $d['address'] ?? null,
        'status'         => $d['status'],
        'cached'         => $d['cached'],
        'request_id'     => $d['request_id'],
    ];
}

Common Validation Mistakes

Start validating EU VAT numbers

Free plan — 100 validations/month. No credit card required.

Related articles