Tutorial12 min readAlberto García

How to Validate EU VAT Numbers with VIES API (and Why Most Teams Use a Wrapper)

Calling VIES directly means SOAP, XML, ambiguous errors, and no caching. Here's how to do it both ways — raw VIES and a REST wrapper — with complete error handling.

vatviestutorialapieutypescriptpython

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.

FieldTypeAvailable fromNotes
validbooleanAll 27 countriesThe authoritative validity flag
namestring | null~18 countriesCompany's registered name (null if not shared)
addressstring | null~18 countriesRegistered address (null if not shared)
countryCodestringAll 27 countriesTwo-letter ISO country code
requestDatestringAll 27 countriesTimestamp 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.

typescriptlib/vies-direct.ts
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'

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.

typescriptlib/vies-wrapper.ts
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)
pythonlib/validate_vat.py
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 later

Handling all 5 status codes correctly

StatusMeaningAt checkoutOn re-validation
activeRegistered and validApply reverse chargeContinue reverse charge
invalidNumber does not existReject — prompt to correctSwitch to B2C treatment, notify customer
inactiveWas registered, now deregisteredReject — prompt to correctSwitch to B2C treatment immediately
format_invalidWrong format for the countryReject — prompt to correctFlag for manual review
service_unavailableVIES temporarily downAllow, flag for re-validationSkip this cycle, retry next run

Country format reference (10 most validated)

CountryPrefixFormatExample
GermanyDEDE + 9 digitsDE123456789
FranceFRFR + 2 alphanum + 9 digitsFRXX123456789
SpainESES + 1 letter + 7 digits + 1 letter/digitESA1234567B
ItalyITIT + 11 digitsIT12345678901
NetherlandsNLNL + 9 digits + B + 2 digitsNL123456789B01
PolandPLPL + 10 digitsPL1234567890
BelgiumBEBE + 10 digits (leading 0)BE0123456789
SwedenSESE + 10 digits + 01SE123456789001
AustriaATAT + U + 8 digitsATU12345678
GreeceELEL + 9 digits (NOT GR!)EL123456789

Start validating EU VAT numbers

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

AG
Alberto García

Founder, TaxID

Building EU VAT validation tools for developers. Obsessed with compliance automation and developer experience.