DEV Community

Cover image for VAT validation: three answers, not two
Oaida Adrian
Oaida Adrian

Posted on

VAT validation: three answers, not two

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}
Enter fullscreen mode Exit fullscreen mode

For RO14399840 it answers:

{
  "isValid": true,
  "userError": "VALID",
  "name": "DANTE INTERNATIONAL SA",
  "address": "MUNICIPIUL BUCUREŞTI, SECTOR 2 …"
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: 0 forces 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

Top comments (0)