A VAT validator that answers "invalid" when the registry is down is worse than one that refuses to answer. It takes a good customer, marks them a compliance risk, and nobody notices until an invoice is queried.
I built one against VIES — the European Commission's own VAT registry — and the interesting part was not the HTTP call. It was the three-way status, and the fact that VIES makes it easy to get wrong.
The endpoint is free and keyless
GET https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{country}/vat/{number}
For RO14399840 it answers:
{
"isValid": true,
"userError": "VALID",
"name": "DANTE INTERNATIONAL SA",
"address": "MUNICIPIUL BUCUREŞTI, SECTOR 2 …"
}
Note userError: "VALID". The verdict is duplicated across two fields, and they disagree in meaning: isValid is the answer, userError carries a code. My first classifier read userError first, found a non-empty string, and reported every valid number as invalid. The fix is one line — trust isValid when it is true, and only look at userError when it is false:
def classify(payload):
if payload.get("isValid") is True:
return {"status": "valid", "reason": None}
error = str(payload.get("userError") or "").strip().upper()
if error in UNAVAILABLE or "CONCURRENT" in error:
return {"status": "unknown", "reason": error}
return {"status": "invalid", "reason": error or None}
The status that matters is the third one
On the day I wrote this, two member states were not answering:
DE811569869 -> unknown MS_UNAVAILABLE
FR40303265045 -> unknown MS_MAX_CONCURRENT_REQ (then valid, twenty minutes later)
MS_UNAVAILABLE, MS_MAX_CONCURRENT_REQ, GLOBAL_MAX_CONCURRENT_REQ, TIMEOUT are all "I could not answer". A two-state validator folds them into invalid, and there is no way for the caller to tell the difference between "this company is not VAT registered" and "Germany is having a bad afternoon".
So the output carries status: valid | invalid | unknown and the raw reason for the third. unknown rows are never billed and never counted as a change — an outage should not look like movement.
Monitoring beats dumping
The second half is a diff. Take a list of VAT numbers, validate them, and on the next run emit only the ones whose answer changed:
{
"vatNumber": "RO14399840",
"status": "valid",
"name": "DANTE INTERNATIONAL SA",
"changed": {"status": false, "name": true, "address": false},
"isChange": true
}
That needs state outside the run. On Apify the default key-value store is per run, so a scheduled run always looks like a first run and never diffs anything — the state has to live in a named store:
store = await Actor.open_key_value_store(name="eu-vat-validator-state")
cached = await store.get_value(f"{country}{number}") # {ts, payload, row}
A quiet day then costs a handful of calls and produces an empty dataset, which is itself the answer.
The parse trap nobody expects
VAT numbers are two letters plus a number — so the string Dedeman parses cleanly as country DE and number DEMAN. A company name silently becomes a query. Real VAT numbers always carry digits (ATU12345678, ESA1234567, NL123456789B01), so the guard is that at least three digits must survive:
if sum(ch.isdigit() for ch in num) < 3:
return None
That one is a test now, along with the userError: "VALID" case, so neither can come back.
Numbers
- Concurrency 3, small delay: VIES throttles per member state, and a burst earns
MS_MAX_CONCURRENT_REQ. - 24 h cache per number by default;
cacheHours: 0forces a live check. - 13 unit tests, pure logic, no network, run inside the Docker build so a wrong verdict cannot ship.
The validator is published as an Apify actor (darknezz/eu-vat-validator). If you are writing your own, the single most useful thing I can tell you is the one above: VIES answers "valid" with userError: "VALID", and it answers "Germany is down" with isValid: false. Those two must not end up in the same bucket.
More from me
- Metering an MCP server by answers, not requests: what Leadgen 2.0 changed
- Romania's company registry, now callable from Claude and Cursor (MCP + REST)
- Romania's company registry has no free API — so I built one (4.2M firms, MCP + REST)
- Building a Romanian Business Registry API: 4.2M Companies as Queryable JSON
- Building a Romania Mall Store Directory: Every Store in 29 Malls, One Schema
Top comments (0)