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.
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.
| Country | Prefix | Format | Example | Notes |
|---|---|---|---|---|
| Germany | DE | DE + 9 digits | DE123456789 | Most straightforward format |
| France | FR | FR + 2 chars + 9 digits | FR12345678901 | First 2 chars can be alpha or digit |
| Spain | ES | ES + char + 7 digits + char | ESX1234567X | First and last can be letter or digit |
| Netherlands | NL | NL + 9 digits + B + 2 digits | NL123456789B01 | B is mandatory, not optional |
| Italy | IT | IT + 11 digits | IT12345678901 | All numeric after prefix |
| Poland | PL | PL + 10 digits | PL1234567890 | 10 digits, not 9 |
| Belgium | BE | BE + 10 digits | BE0123456789 | Leading zero is valid |
| Sweden | SE | SE + 12 digits | SE123456789001 | 12 digits, ends in 01 |
| Greece | EL | EL + 9 digits | EL123456789 | Uses EL not GR — common mistake |
| Portugal | PT | PT + 9 digits | PT123456789 | 9 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.
// 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.
# 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:
| Field | Type | Meaning |
|---|---|---|
| valid | boolean | true only if VIES confirmed the number is actively registered. false for invalid, inactive, or unavailable. |
| status | string | active | inactive | format_invalid | service_unavailable. Always check this, not just valid. |
| vat | string | The normalised full VAT number as submitted (e.g. DE123456789). |
| country_code | string | Two-letter VIES country code. EL for Greece, not GR. |
| company_name | string | null | Business name from VIES. null for some member states that do not share this field. |
| address | string | null | Registered address from VIES. null for member states that redact address data. |
| cached | boolean | true if this response was served from Redis cache (sub-10ms). false if a live VIES call was made. |
| request_id | string | Unique 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.
// 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.
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.
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.
- →B2B intra-community supply: seller in EU country A, buyer in EU country B, buyer provides a valid VAT number → zero-rate VAT applies. Buyer accounts for VAT in their own country via reverse charge. You must validate the VAT number before issuing the zero-rate invoice.
- →B2C or invalid/missing VAT number: charge VAT at the buyer's country rate (if you are registered for OSS or have a local VAT registration in that country) or your own country's rate if below the pan-EU threshold. No validation needed — just charge VAT.
- →Domestic sales (seller and buyer in same EU country): local VAT rules apply. A buyer having a valid VAT number does not trigger zero-rate on domestic transactions — only cross-border intra-community supplies are zero-ratable.
- →Non-EU sales: outside EU VAT scope entirely. Do not charge EU VAT. Some non-EU countries (UK post-Brexit, Norway, Switzerland) have separate VAT systems — those require separate handling.
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:
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
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
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
- →Checking valid without checking status: if status is service_unavailable, valid is false — but the number may be perfectly valid. Always check status first.
- →Using GR instead of EL for Greece: the VIES prefix for Greece is EL, not the ISO code GR. The TaxID API normalises both, but raw VIES calls will fail with GR.
- →Skipping format validation: sending malformed inputs to VIES wastes quota. Validate format locally before making the API call.
- →Not re-validating periodically: VAT registration lapses. A valid number at signup can become invalid in 6 months. Re-validate quarterly for active subscriptions.
- →Storing only the boolean: store the full response including company_name, address, status, checkedAt, and request_id. You need this for audit trails.
- →Validating in the frontend: VAT validation must be server-side. Exposing your API key to the browser creates a security risk and allows clients to bypass validation.
- →Treating B2B and B2C the same: apply zero-rate only to confirmed active B2B customers in other EU countries. Never apply zero-rate to domestic sales regardless of VAT number status.
- →Ignoring inactive status: if VIES returns inactive, the business has deregistered. Switch them to B2C billing immediately and stop issuing zero-rate invoices.
Related guides on taxid.dev
Express.js middleware, circuit breaker patterns, and background re-validation jobs
End-to-end guide: collect, validate, classify, invoice
Server-side zero-rate exemption before payment intent creation
DAC7 obligations and implementation for EU platforms
Full endpoint documentation, error codes, and rate limits
Start validating EU VAT numbers
Free plan — 100 validations/month. No credit card required.