Validate EU VAT Numbers via API: Developer Guide

For PHP applications, follow the PHP and Laravel VAT validation guide.

If you sell B2B in the EU, you need to validate your customer's VAT number before applying the reverse charge mechanism. Getting this wrong means you either charge VAT when you shouldn't (annoying your customer) or skip it when you should (creating a tax liability). This guide covers how to validate VAT numbers programmatically using a REST API, with examples for EU, UK, and other supported regions.

Why does VAT validation matter?

Under EU VAT rules, B2B cross-border transactions within the EU can be zero-rated if the buyer provides a valid VAT identification number. This is the "reverse charge" mechanism: the tax obligation shifts from the seller to the buyer.

To apply the reverse charge, you must verify that the buyer's VAT number is valid and active at the time of the transaction. "Valid format" isn't enough. The number must be registered and active with the relevant tax authority. This is also important for fraud prevention: fake VAT numbers are a common vector for VAT fraud.

Validation is also something you may need to prove later. If a buyer's number turns out to be invalid after you zero-rated an invoice, the burden of showing that you checked falls on you. That is what consultation numbers are for: when you pass your own VAT number as the requester, the validation response includes a timestamped identifier you can store alongside the invoice as audit evidence. Validate before you invoice, and keep the result on file.

How does VIES actually work?

The European Commission operates VIES (VAT Information Exchange System), a service that checks VAT numbers against national tax authority databases across all 27 EU member states. When you query VIES with a VAT number, it routes the request to the relevant country's tax authority and returns whether the number is valid. VIES holds no data of its own, so latency and availability vary by country: in our monitoring, a live lookup typically takes 500ms to 3 seconds depending on which member state answers.

For a direct integration using the Commission's current JSON endpoint, see the VIES REST and SOAP API guide. It includes a runnable Node.js example and shows how an HTTP 200 response can still contain a service fault.

VIES exposes a SOAP/XML endpoint. A typical request looks like this:

POST https://ec.europa.eu/taxation_customs/vies/services/checkVatService
Content-Type: text/xml

<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
  xmlns:urn="urn:ec.europa.eu:taxud:vies:services:checkVat:types">
  <soapenv:Body>
    <urn:checkVat>
      <urn:countryCode>DE</urn:countryCode>
      <urn:vatNumber>123456789</urn:vatNumber>
    </urn:checkVat>
  </soapenv:Body>
</soapenv:Envelope>

The response is XML containing a valid boolean, the company name, and address (when available).

Pain points of using VIES directly

VIES works, but building a production integration against it is painful:

  • SOAP/XML: You need to construct XML payloads and parse XML responses. Most modern stacks don't have great SOAP support.
  • Inconsistent availability: VIES depends on each member state's national service, and there's no SLA. In our uptime monitoring, some countries (Italy, Spain) show frequent downtime windows.
  • Undocumented rate limits: VIES applies per-IP limits that aren't published. Exceed them and you get a MS_MAX_CONCURRENT_REQ fault with no Retry-After header.
  • One number per request: There's no batch operation, so bulk jobs mean sequential calls against those same opaque limits.
  • No UK support: Since Brexit, UK VAT numbers aren't in VIES. You need a separate integration with HMRC's API.
  • No caching: If VIES is down for a country, your validation fails. You need to build your own caching layer.
  • No test mode: You're always hitting the live service, making integration testing unreliable.

The modern approach: using a REST API

Instead of integrating with VIES directly, you can use a REST API that wraps VIES (and HMRC) and handles caching, error handling, and normalization for you.

Here's a validation request using curl and Avatcado:

curl https://api.avatcado.com/v1/validate?vat_number=DE123456789 \
  -H "Authorization: Bearer avat_live_your_api_key"

The response is structured JSON:

{
  "data": {
    "valid": true,
    "vat_number": "DE123456789",
    "country_code": "DE",
    "company": {
      "name": "ACME GmbH",
      "address": "Musterstraße 1, 10115 Berlin"
    },
    "consultation_number": null,
    "requested_at": "2026-03-17T10:30:00Z"
  },
  "meta": {
    "request_id": "550e8400-e29b-41d4-a716-446655440000",
    "request_duration_ms": 1240,
    "source_status": "live"
  }
}

The meta object tells you how trustworthy the answer is. source_status: "live" means the upstream authority confirmed the result just now. meta.cached: true means the result came from Avatcado's cache, with meta.cached_at telling you when it was originally fetched. And meta.request_id identifies the request if you need to reference it in a support conversation or your own logs.

To request a consultation number, add requester_vat_number with your genuine VAT number as a query parameter. Store consultation_number with requested_at when returned; the reference is not guaranteed. Supplying a requester disables cache reads and national register fallback by default; pass cache=true or fallback=true to opt back in.

Code examples

TypeScript / Node.js

const response = await fetch(
  "https://api.avatcado.com/v1/validate?vat_number=DE123456789",
  {
    headers: {
      Authorization: "Bearer avat_live_your_api_key",
    },
  }
);

const { data, meta } = await response.json();

if (data.valid) {
  console.log(`Valid VAT: ${data.company.name}`);
  if (meta.cached) {
    console.log("Result served from cache");
  }
} else {
  console.log("Invalid VAT number");
}

Python

import requests

response = requests.get(
    "https://api.avatcado.com/v1/validate",
    params={"vat_number": "DE123456789"},
    headers={"Authorization": "Bearer avat_live_your_api_key"},
)

result = response.json()

if result["data"]["valid"]:
    print(f"Valid VAT: {result['data']['company']['name']}")
else:
    print("Invalid VAT number")

If you'd rather not write HTTP calls by hand, the typed SDKs (@avatcado/node on npm, avatcado on PyPI) wrap the same endpoint with a { data, error } pattern and typed error classes, so rate limit and upstream errors are distinct types you can match on.

Handling errors: what each code means

Every error response carries a machine-readable code, a human-readable message, a docs_url pointing at the fix, and (for validation requests) the normalized vat_number you submitted, echoed back so your logs record which number failed even when the request itself errors. The codes you'll actually encounter during validation:

  • invalid_vat_format (422): The number doesn't match any supported country's format. This is an input problem, not a transient failure. Fix the input; retrying returns the same error.
  • unauthorized (401): Missing or invalid API key. Check that you're sending the right key for the right environment (avat_live_ vs avat_test_).
  • burst_limit_exceeded (429): You exceeded the per-minute limit for your tier. The Retry-After header tells you how many seconds remain in the current 60-second window.
  • rate_limit_exceeded (429): Your monthly quota is exhausted. Retry-After counts down to your next reset, and X-RateLimit-Reset gives the exact timestamp.
  • upstream_unavailable / upstream_member_state_unavailable (503): The upstream registry (or one specific VIES member state) is down and no cached result existed to fall back on. These requests are automatically refunded to your quota, because you received no data.
  • tier_insufficient (403): The feature needs a higher tier, for example batch validation on the free plan.

A sane retry policy follows from the codes: retry 429 and 503 responses after the Retry-After interval, and never retry 4xx input or auth errors, because they'll fail identically every time. Validation is a GET, so retries are safe. Set your HTTP client timeout with live lookups in mind: a fresh VIES lookup can take a few seconds when a slow member state answers, while cached results return in milliseconds.

What happens when VIES goes down?

Avatcado caches every validation result, keyed on the VAT number plus the requester number if you sent one. Repeat lookups within that window return from cache in milliseconds with meta.cached: true. Cached responses still count toward your monthly quota; the cache is a performance and resilience layer, not a billing bypass.

The cache is what keeps your integration alive during an outage. When a member state is down, Avatcado on the Pro and Business plans first consults the country's national tax register for Belgium, Croatia, Czechia, Estonia, Finland, France, Latvia, Romania, Slovakia and Slovenia, and serves a confirmed registration with meta.source_status: "fallback" and meta.source naming the register. Otherwise it falls back to the most recent cached result regardless of age and marks the response with meta.source_status: "unavailable", plus meta.stale: true when it is older than the cache window. Your code can then decide: accept a stale "valid" for a returning customer, or hold the transaction until a fresh check succeeds. Check meta.cached_at to see how old the data is.

There's a subtler failure mode too: some member states return valid: false instead of a proper error when their systems are degraded. Avatcado compares suspicious invalid answers against its cache and serves the cached result with source_status: "degraded" rather than passing a silent false negative through, so you don't refuse the reverse charge to a legitimately registered buyer. If no eligible source can answer, the API returns a refunded 503. Pass cache=false to request a fresh attempt, but without a requester an outage can still return stored evidence. A request that carries requester_vat_number disables stored responses and national fallback by default, so with those defaults an upstream outage returns a refunded 503. Explicit true flags opt back into the corresponding path. For a deeper look at outage handling, see the VIES downtime guide.

What about UK, Swiss, and Norwegian numbers?

The same endpoint handles UK, Swiss (CHE prefix), Liechtenstein (LI prefix), and Norwegian (NO prefix) VAT numbers. Avatcado detects the country prefix and routes the request to the appropriate national tax authority. For the exact format and check digit rules per country, see the VAT number format reference:

curl https://api.avatcado.com/v1/validate?vat_number=GB123456789 \
  -H "Authorization: Bearer avat_live_your_api_key"

The response format is identical. You don't need conditional logic based on the country. A few country-specific gotchas worth knowing:

  • UK (GB): Since Brexit, GB numbers live in HMRC's database, not VIES. HMRC's own API requires OAuth 2.0 client credentials and has a different response format; Avatcado routes GB numbers there for you. See the HMRC VAT check guide for the details.
  • Northern Ireland (XI): XI-prefixed numbers stay in VIES, because Northern Ireland remains in the EU single market for goods. XI validates like an EU number.
  • Greece (EL): Greek numbers use the EL prefix, not GR. Sending GR fails format validation.
  • Switzerland and Liechtenstein (CHE, LI): Validated against the BFS UID Register. A company can have a valid UID without being VAT-registered; in that case the response is valid: false with the company details still present.
  • Norway (NO): Validated against the Bronnoysund Register, with the same distinction between an organization that exists and one that is actually MVA-registered.
  • Australia (AU): ABNs are validated against the Australian Business Register through the same endpoint.

Input is normalized before validation: uppercased, with spaces, dots, and dashes stripped. Sending de 123.456.789 validates as DE123456789, so you don't need to sanitize what users paste into your checkout form.

Validating a list of VAT numbers

For migrations, CRM cleanups, or bulk invoice runs, validating one number at a time is slow. The batch endpoint accepts up to 50 numbers per request (Pro and Business tiers):

curl -X POST https://api.avatcado.com/v1/validate/batch \
  -H "Authorization: Bearer avat_live_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{"vat_numbers": ["DE123456789", "FR82542065479", "GB123456789"]}'

Each number is validated independently and results come back in input order, with per-item errors inline rather than failing the whole request, plus a summary of how many succeeded and failed. Quota is charged per unique number (duplicates in a batch are free), and the whole batch counts as one hit against your per-minute burst limit. For larger jobs, the async endpoints take up to 200 numbers per batch on Pro and 1,000 on Business, and deliver results to your webhook signed with HMAC-SHA256; the async validation guide covers that flow end to end.

How do you test without hitting VIES?

Use a test-mode API key (prefixed avat_test_) with magic VAT numbers to simulate different scenarios without hitting VIES or HMRC:

# Always returns valid
curl https://api.avatcado.com/v1/validate?vat_number=DE111111111 \
  -H "Authorization: Bearer avat_test_your_test_key"

# Always returns invalid
curl https://api.avatcado.com/v1/validate?vat_number=DE000000000 \
  -H "Authorization: Bearer avat_test_your_test_key"

Test requests never reach an upstream registry, don't consume your monthly quota, and are exempt from the burst limit, so CI can hammer them. Every test response includes meta.mode: "test" so you can assert you're not accidentally testing against live. Beyond the valid/invalid pair, there are magic numbers for the failure paths your code should handle: DE999999999 triggers a 503 upstream_unavailable, DE777777777 a 429 rate_limit_exceeded, and DE555555555 a stale-cache response with source_status: "unavailable", so you can test your outage handling deterministically. Any other correctly formatted number returns a generic valid result, while format validation still applies, so malformed input fails in tests exactly as it would in production. See the Avatcado documentation for the full list of magic numbers and error simulations.

Get started

Avatcado offers a free tier with 500 validations per month, no credit card required. The API, response format, and caching work the same across all plans; Pro raises the quota to 10,000 validations per month and Business to 50,000, with higher per-minute limits to match. The same endpoint also validates Swiss (CHE), Liechtenstein, Norwegian (MVA), and Australian (ABN) numbers.

Start validating for free →

Frequently asked questions

What is a VAT identification number?

A VAT identification number is a unique identifier assigned to businesses registered for Value Added Tax in the EU or UK. It consists of a two-letter country prefix followed by digits, and in some countries letters, for example DE123456789 for Germany or GB123456789 for the UK. The prefix tells you which registry holds the registration: EU numbers (plus XI for Northern Ireland) live in VIES, GB numbers in HMRC's database, CHE numbers in the Swiss BFS UID Register, NO numbers in Norway's Bronnoysund Register, and AU-prefixed ABNs in the Australian Business Register. For B2B sales the number matters because a valid, active VAT number is what lets you apply the reverse charge and zero-rate a cross-border invoice. Format alone proves nothing, since a number can look correct and still be unregistered or deactivated, so you confirm it against the registry with a live lookup before deciding the tax treatment.

Can I validate UK VAT numbers with VIES?

No. Since the end of the Brexit transition period, UK VAT numbers with the GB prefix are no longer in VIES, and querying VIES for one fails. UK validation goes through HMRC's VAT Registered Companies API instead, which is a separate integration with its own OAuth 2.0 client credentials authentication, its own response format, and its own error behavior (it returns a 404 for numbers that are not registered rather than a structured invalid result). The one exception is Northern Ireland: XI-prefixed numbers remain in VIES because Northern Ireland stays in the EU single market for goods. Avatcado removes the split entirely. The same GET /v1/validate endpoint accepts both GB and EU numbers, reads the country prefix, and routes the request to HMRC or VIES automatically, returning the same JSON envelope either way, so your code needs no country-specific branches for UK customers.

How long does a VAT validation take?

In our monitoring, a live VIES lookup typically takes 500ms to 3 seconds depending on which member state's tax authority answers the request, and HMRC lookups average around 1 second. Latency varies because VIES is a routing layer: every query travels to the relevant national database, and some countries respond much more slowly than others. Avatcado's cache changes the picture for repeat lookups. A recently validated number returns from cache in milliseconds with meta.cached: true, with no upstream round trip at all. Every response also includes meta.request_duration_ms so you can track real latency in your own logs. For checkout flows, validate as soon as the customer finishes typing the number rather than at final submit, so the round trip overlaps with the rest of the form. In test mode, magic numbers return instantly, which keeps CI runs fast and deterministic.

Do I need to validate VAT numbers for B2C sales?

No. VAT validation exists to support B2B tax treatment: a valid, active VAT number is what lets you apply the reverse charge and zero-rate a cross-border invoice, so it only matters when the buyer claims to be a VAT-registered business. Consumers do not have VAT numbers, and for B2C sales you charge VAT at the applicable rate regardless of what the buyer enters. The practical consequence for your checkout is that the VAT number field should be optional. Treat an empty field as B2C and charge VAT. Treat a filled field as a claim to business status that you verify with a live registry lookup before honoring it. If the lookup comes back invalid, fall back to the B2C path and charge VAT rather than blocking the sale. The presence of a valid VAT number is effectively your primary signal for distinguishing a B2B transaction from a B2C one.

Sources

Related guides