Quick answer
If you are validating EU VAT numbers against VIES, three things will bite you, and none of them are in the part of the docs people read:
-
Greece is
EL, notGR. VIES uses the VAT-prefix convention, not ISO 3166. -
Northern Ireland is
XIโ a post-Brexit code that exists in VIES and in almost no country list you already have. -
Germany and Spain answer
---for company name and address, on valid numbers. That is a deliberate non-disclosure policy, not a parsing bug and not an invalid result.
Get any of those wrong and you will ship a compliance dataset that quietly marks real Greek companies as unvalidatable.
The country list you already have is wrong ๐ช๐บ
When we built the VIES VAT Checker, the first instinct was the obvious one: take the 27 EU member states, grab their ISO 3166 alpha-2 codes, validate the prefix against that.
That list is wrong in two places, and both are silent failures.
We didn't guess. We asked the service what it validates, live:
curl -s https://ec.europa.eu/taxation_customs/vies/rest-api/check-status \
| python3 -c "import json,sys; print(sorted(c['countryCode'] for c in json.load(sys.stdin)['countries']))"
['AT','BE','BG','CY','CZ','DE','DK','EE','EL','ES','FI','FR','HR','HU','IE',
'IT','LT','LU','LV','MT','NL','PL','PT','RO','SE','SI','SK','XI']
Read that carefully against ISO 3166:
-
ELis there.GRis not. Greece's VAT prefix has always beenEL(from Ellรกs). ISO saysGR. VIES speaks VAT, not ISO. If your allow-list came from a standard country library, every Greek VAT number you submit is rejected by your code before VIES ever sees it. -
XIis there, and it is not a country. After Brexit, Great Britain left the EU VAT area but Northern Ireland stayed inside it for goods.XIis the VAT prefix for Northern Irish traders.GBis gone from the list entirely. - That is 28 codes for 27 member states โ which is the tell that this is not a country list at all.
The fix is not clever. It is just refusing to derive the list from a different standard:
# EU member-state codes VIES actually validates against โ confirmed live
# 2026-09-06 via `GET /check-status`'s `countries[].countryCode` list.
# Includes `EL` (Greece, not `GR`) and `XI` (Northern Ireland post-Brexit
# special status) per VIES's own convention.
MEMBER_STATE_CODES = frozenset({
"AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "EL", "ES", "FI", "FR",
"HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
"SE", "SI", "SK", "XI",
})
Dating the comment matters more than usual here. This list has changed twice in living memory โ once when Brexit removed GB and invented XI, once when Croatia joined. It will change again.
--- is a valid answer, not a missing one
Submit a valid German VAT number and VIES tells you it is valid, then hands you this:
{ "isValid": true, "name": "---", "address": "---" }
That is not an outage and not a truncation. Germany and Spain have decided, as a matter of national policy, not to disclose trader name and address through VIES. The number is confirmed; the identity is withheld.
This matters because of what a naive pipeline does with it. If you treat name: "---" as "lookup failed" and retry, you will hammer a shared European Commission service forever for data it is never going to give you. If you treat it as an empty string, your downstream name-match step will report a mismatch against a customer record that is perfectly correct.
So --- gets a sentinel of its own, and the row says disclosed: false rather than pretending the name is unknown:
VIES_DASH_SENTINEL = "---" # DE/ES policy: never disclose name/address (REQ-2).
A clean negative is a result you paid for ๐งพ
The deeper design point, and the one that decides whether a bulk validator is usable at all: an invalid VAT number is a successful lookup.
This sounds obvious and almost every implementation gets it wrong, because the HTTP-shaped instinct is to map "not valid" onto an error path. Do that at batch scale and one dud in a 5,000-row upload takes the run down with it โ the classic failure where a recoverable per-item condition is allowed to crash the whole job.
Every entry lands as its own row with its own status:
-
validโ VIES confirmed it -
invalidโ VIES confirmed it is not registered. This is a useful, billable answer; it is the whole point of a compliance check. -
unparseableโ we could not even form a(country, number)pair, so VIES was never called. Costs nothing. -
service_unavailableโ that member state's node was down. Distinct frominvalid, because the difference is the difference between "reject this customer" and "try again in an hour".
That last distinction is the one that protects you legally. Collapsing a member-state outage into "invalid" means your onboarding flow declines a real business because a government server was rebooting.
There is also a pre-flight for it. checkServiceStatus calls check-status once, before any lookups, and writes a row telling you which member states are currently available โ so you find out that Italy is down before you spend a run against it, not after.
Input shapes, normalised early
Real customer lists are not clean. Ours accepts all of these and normalises before a single request goes out:
DE811907980 glued
DE 811907980 spaced
{"country": "DE", "number": "811907980"} structured
# Matches a 2-letter ISO/VIES country prefix followed by the VAT digits,
# glued ("DE811907980") or spaced ("DE 811907980") โ no other whitespace
# or punctuation is accepted between prefix and number.
COUNTRY_PREFIX_PATTERN = re.compile(r"^([A-Za-z]{2})\s*([A-Za-z0-9]+)$")
Deliberately strict about what it won't accept. DE-811907980 and DE.811 907 980 are rejected as unparseable rather than silently repaired, because a validator that guesses at malformed identifiers is worse than one that tells you which rows to fix.
And VIES is a shared EC service that throttles hard, so concurrency is capped and 429/5xx get exponential backoff. A polite client finishes a 5,000-row batch. An aggressive one gets a slow, unpredictable middle finger.
The part that generalises ๐งญ
Every one of these bugs comes from the same move: filling in a third-party service's vocabulary from a standard instead of from the service. ISO 3166 is a perfectly good standard. It is just not the one VIES speaks. The thirty-second check-status call is the whole fix, and it was available the entire time.
When an API's domain looks like something you already have a library for โ countries, currencies, timezones, language codes โ that resemblance is the trap. Ask the API.
What the Actor gives you
One row per requested number, always โ the row count you get back matches the row count you sent in:
- validity, plus registered name and address where the member state actually discloses them
-
ELandXIhandled correctly, and---reported as non-disclosure rather than missing data - explicit, separate statuses for invalid / unparseable / member-state-unavailable
- optional
check-statuspre-flight row showing per-state availability before the batch runs - Pydantic-validated rows, so field names and types are stable across runs
The honest limitations ๐ง
- VIES is the authority; we do not second-guess it. If a member state's node is down, we report that rather than inventing a result.
- Name and address availability is set by each member state, not by us. DE and ES will keep saying
---. - This confirms VAT registration. It is not a sanctions screen, a solvency check, or a substitute for your own KYC obligations.
Pricing
$0.20 per run plus $0.001 per number checked โ about $1.20 per 1,000 lookups. Priced for recurring batch compliance work rather than one-off lookups. Unparseable input costs nothing.
Built by Devil Scrapes. We handle the country-code conventions, the non-disclosure sentinels, the per-state outages and the throttling, so you get a flat table instead of a weekend.
Top comments (0)