VIES (VAT Information Exchange System) is the European Commission's official system for validating EU VAT registration numbers in real time. It is the only legally authoritative source for EU VAT validation — when you call it and get a positive result, you have fulfilled your obligation to verify the buyer's taxable status under the EU VAT Directive. The problem is that calling it directly is painful: it uses SOAP/XML, returns one error for both 'invalid' and 'service unavailable', has no caching, and averages 1–4 seconds per request. This guide shows you both approaches.
What VIES actually returns
VIES returns five fields. Not all of them are populated for all EU countries — some member states don't share company name and address data through VIES.
| Field | Type | Available from | Notes |
|---|---|---|---|
| valid | boolean | All 27 countries | The authoritative validity flag |
| name | string | null | ~18 countries | Company's registered name (null if not shared) |
| address | string | null | ~18 countries | Registered address (null if not shared) |
| countryCode | string | All 27 countries | Two-letter ISO country code |
| requestDate | string | All 27 countries | Timestamp of the VIES query |
Warning
Greece uses the prefix 'EL' in VIES, not the ISO 3166 code 'GR'. If you pass GR for a Greek VAT number, the call will either fail or return invalid — always use EL. This is the most common integration bug for teams building multi-country VAT validation.
Option 1: Call VIES directly (SOAP)
The VIES SOAP endpoint is at `https://ec.europa.eu/taxation_customs/vies/services/checkVatService`. The following example uses the `node-soap` package. In production you would also need to handle WSDL caching, connection timeouts, XML parsing errors, and the MS_UNAVAILABLE fault code.
import soap from 'node-soap';
const WSDL = 'https://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl';
export async function validateViesDirectly(
countryCode: string,
vatNumber: string
) {
// Creating a new SOAP client on every call is expensive — cache it
const client = await soap.createClientAsync(WSDL, { timeout: 10000 });
try {
const [result] = await client.checkVatAsync({
countryCode,
vatNumber: vatNumber.replace(/^[A-Z]{2}/, ''), // Strip country prefix
});
return {
valid: result.valid,
name: result.name ?? null,
address: result.address ?? null,
};
} catch (err: unknown) {
// VIES SOAP faults are not well-typed — check the message string
const message = (err as Error).message ?? '';
if (message.includes('MS_UNAVAILABLE') || message.includes('SERVICE_UNAVAILABLE')) {
// The member state's VIES node is down — this is NOT the same as invalid
throw new Error('VIES_SERVICE_UNAVAILABLE');
}
throw err;
}
}
// Problems with this approach:
// - No caching: every call hits VIES, averaging 800ms–2500ms
// - MS_UNAVAILABLE for invalid looks the same as service down in many SOAP parsers
// - WSDL parsing is slow unless you cache the client
// - No distinction between 'never existed' vs 'previously registered, now inactive'Option 2: Use a REST wrapper (recommended)
A REST wrapper handles the SOAP complexity, caches responses, and returns machine-readable status codes that distinguish between all five outcomes. The following examples use the TaxID API.
export async function validateVat(countryCode: string, vatNumber: string) {
const res = await fetch(
`https://www.taxid.dev/api/v1/validate/${countryCode}/${vatNumber}`,
{
headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
signal: AbortSignal.timeout(8000),
}
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{
valid: boolean;
status: 'active' | 'invalid' | 'inactive' | 'format_invalid' | 'service_unavailable';
company_name: string | null;
company_address: string | null;
request_id: string;
}>;
}
// Advantages over calling VIES directly:
// - REST + JSON instead of SOAP + XML
// - Cached warm responses in < 10ms
// - 5 distinct status codes instead of SOAP fault strings
// - 'inactive' distinguishes deregistered from never-existed
// - 31 countries via a single endpoint (VIES covers only EU27)import requests
from typing import Literal
StatusCode = Literal['active', 'invalid', 'inactive', 'format_invalid', 'service_unavailable']
def validate_vat(country_code: str, vat_number: str) -> dict:
r = requests.get(
f'https://www.taxid.dev/api/v1/validate/{country_code}/{vat_number}',
headers={'Authorization': f'Bearer {TAXID_API_KEY}'},
timeout=10,
)
r.raise_for_status()
return r.json()
# Usage
result = validate_vat('DE', 'DE123456789')
match result['status']:
case 'active': # Valid — apply reverse charge
case 'invalid': # Does not exist
case 'inactive': # Was valid, now deregistered
case 'format_invalid': # Bad format for DE
case 'service_unavailable': # VIES temporarily down — allow, retry laterHandling all 5 status codes correctly
| Status | Meaning | At checkout | On re-validation |
|---|---|---|---|
| active | Registered and valid | Apply reverse charge | Continue reverse charge |
| invalid | Number does not exist | Reject — prompt to correct | Switch to B2C treatment, notify customer |
| inactive | Was registered, now deregistered | Reject — prompt to correct | Switch to B2C treatment immediately |
| format_invalid | Wrong format for the country | Reject — prompt to correct | Flag for manual review |
| service_unavailable | VIES temporarily down | Allow, flag for re-validation | Skip this cycle, retry next run |
Country format reference (10 most validated)
| Country | Prefix | Format | Example |
|---|---|---|---|
| Germany | DE | DE + 9 digits | DE123456789 |
| France | FR | FR + 2 alphanum + 9 digits | FRXX123456789 |
| Spain | ES | ES + 1 letter + 7 digits + 1 letter/digit | ESA1234567B |
| Italy | IT | IT + 11 digits | IT12345678901 |
| Netherlands | NL | NL + 9 digits + B + 2 digits | NL123456789B01 |
| Poland | PL | PL + 10 digits | PL1234567890 |
| Belgium | BE | BE + 10 digits (leading 0) | BE0123456789 |
| Sweden | SE | SE + 10 digits + 01 | SE123456789001 |
| Austria | AT | AT + U + 8 digits | ATU12345678 |
| Greece | EL | EL + 9 digits (NOT GR!) | EL123456789 |
Related resources
Start validating EU VAT numbers
Free plan — 100 validations/month. No credit card required.