EU VAT validation API
Last updated 11 November 2026
What this API does
validate_vat checks an EU VAT number in two steps. First it normalizes the input (spaces, dots and dashes are ignored, GR becomes EL) and checks the national format and checksum locally, for the 27 member states and XI (Northern Ireland). Then it queries VIES, the European Commission service, live through its official REST API. The name and address registered in VIES are not returned (VIES terms of use): send your own name and/or address to get name_match / address_match booleans, computed locally.
Automated check: local format/checksum rules plus a live query of VIES (European Commission). Not a tax attestation and not legal advice; VIES data come from the member states' databases. Keep your own record of the VIES consultation.
Statuses
valid: VIES answered valid.invalid: only when the local format/checksum fails (invalid_reason=format,length,componentorchecksum; VIES is not queried) or when VIES explicitly answers valid=false (vies_valid_false).unverified_ms_down: the member state system did not answer (MS_UNAVAILABLE, MS_MAX_CONCURRENT_REQ, TIMEOUT) after up to 3 attempts (2 for MS_MAX_CONCURRENT_REQ) with backoff and jitter inside an 8 s budget.last_knownis kept for compatibility and is always null: a definitive answer younger than 24 h is returned directly asvalid/invalidfrom the cache.unverified: VIES itself or the connection failed (GLOBAL_MAX_CONCURRENT_REQ, SERVICE_UNAVAILABLE, network error, HTTP 429/5xx;checks.vies.scope=vies_central), the request was not sent because of our own limits (pacing, concurrency queue full) or VIES returned another error.
The API never returns invalid because VIES is down.
VIES result cache
VIES treats checking the same number several times a day as abusive use, so results are cached: valid/invalid for 24 h, unverified/unverified_ms_down for 10 min (checks.vies.retry_after_s = when VIES will be asked again). The cache is shared by all server instances (Firestore vies_cache, keyed by an HMAC of the number, never the number itself) and concurrent requests for the same number share one VIES call. checked_at is the time of the original VIES check and cache_age_s its age in seconds (0 = checked now; null = VIES not involved). With a cached answer, checks.vies.source = cache and checks.vies.cache_scope = instance, inflight or shared. When the answer comes from another instance (shared), name_match/address_match are null, because the VIES name and address are never stored outside instance memory. If the cache is unavailable, VIES is queried directly.
Free key
POST /api/v0/keys with an empty JSON object. No email is required. The key is returned once; only its hash is stored. The free daily quota is 20 validate_vat calls, reset at midnight Europe/Rome. Issuance is rate-limited per IP (the address is hashed, never stored in plaintext) and may be refused when the daily read budget is high (HTTP 503) or when the daily issuance cap is reached (HTTP 429).
curl examples
# 1. Free key (shown once)
curl -sS -X POST https://aether-layer.com/api/v0/keys \
-H 'content-type: application/json' \
--data '{}'
# 2. Validate a VAT number (POST)
curl -sS -X POST https://aether-layer.com/api/v0/validate \
-H "Authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
--data '{"vat_id":"IT00905811006","name":"Eni SpA","address":"Piazzale Enrico Mattei, 1 - 00144 Roma (RM)"}'
# 3. Same check with GET (no name/address in the query string)
curl -sS 'https://aether-layer.com/api/v0/validate?vat_id=IT00905811006' \
-H "Authorization: Bearer $API_KEY"
Response (real VIES answer recorded on 11 November 2026 for IT00905811006, replayed through the server code; disclaimer and notes shortened):
{
"ok": true,
"schema": "aether.vat.validate.v1",
"vat_id": "IT00905811006",
"country": "IT",
"number": "00905811006",
"status": "valid",
"invalid_reason": null,
"checks": {
"local": {
"ok": true,
"algorithm": "it.iva (office code + luhn)",
"failure": null
},
"vies": {
"queried": true,
"source": "live",
"result": "valid",
"error": null,
"attempts": 1,
"latency_ms": 652,
"request_date": "2026-11-11T17:19:36.118Z",
"cache_scope": null,
"age_seconds": 0,
"cache_ttl_seconds": 86400
}
},
"name_match": true,
"address_match": true,
"match_method": "aether.vat.match.v1",
"last_known": null,
"notes": [
{
"country": "IT",
"code": "vies_opt_in_it",
"text": "Italy: inclusion in the VIES archive is an option of the tax…",
"source": "…"
}
],
"latency_budget_ms": 8000,
"checked_at": "2026-11-11T17:19:36.770Z",
"cache_age_s": 0,
"disclaimer": "…",
"quota": {
"daily": 20,
"used": 1,
"remaining": 19,
"window": "2026-11-11",
"timezone": "Europe/Rome"
},
"outcome_id": "lo_…"
}
When the member state is busy (real VIES fault recorded on the same day for FR):
{
"ok": true,
"schema": "aether.vat.validate.v1",
"vat_id": "FR59542051180",
"country": "FR",
"number": "59542051180",
"status": "unverified_ms_down",
"invalid_reason": null,
"checks": {
"local": {
"ok": true,
"algorithm": "fr.tva (SIREN luhn + key mod 97 / alphanumeric key)",
"failure": null
},
"vies": {
"queried": true,
"source": "live",
"result": "error",
"error": "MS_MAX_CONCURRENT_REQ",
"scope": "member_state",
"retryable": true,
"retry_after_s": 600,
"attempts": 2,
"max_attempts": 2,
"latency_ms": 864,
"cache_scope": null,
"age_seconds": 0,
"cache_ttl_seconds": 600
}
},
"last_known": null,
"notes": [],
"latency_budget_ms": 8000,
"checked_at": "2026-11-11T17:19:42.864Z",
"cache_age_s": 0,
"disclaimer": "…",
"quota": {
"daily": 20,
"used": 1,
"remaining": 19,
"window": "2026-11-11",
"timezone": "Europe/Rome"
},
"outcome_id": "lo_…"
}
Malformed input or an unsupported prefix returns HTTP 400 with error = invalid_input.
Name and address match
Optional name and address (max 200 characters each) go only in the JSON body of the POST or in the MCP arguments, never in the query string (HTTP 400). They are compared locally with the VIES record and are not sent to VIES, not logged and not stored. The answer contains only name_match / address_match (true, false, or null when VIES has no data for that field, e.g. Germany, or did not answer) and match_method = aether.vat.match.v1.
Normalization: accents removed, case-folded, punctuation and line breaks become spaces, spaces collapsed, single letters joined (S.p.A. → spa); for names, legal forms at the start or end are removed (srl, spa, gmbh, ag, sarl, sa, bv, ltd, sp. z o.o., …). name_match: normalized names are equal. address_match: the two word sets overlap by at least 75% (Jaccard); abbreviations are not expanded.
VIES is opt-in in some countries
A VIES "invalid" means that the number is not in the member state's VIES data: it may not exist, may not be activated for intra-EU transactions, or its registration may not be finalised yet (Your Europe). In Italy inclusion in the VIES archive is an option of the taxpayer (art. 35 DPR 633/1972, Agenzia delle Entrate); in Spain a number appears in VIES only after registration in the ROI (AEAT). Responses for IT and ES carry this note in notes.
VIES status
GET https://aether-layer.com/vies-status?window=1d|7d (no key) returns, per member state, the VIES attempts made by this gateway: availability rate, outcome counts and p50/p95 latency. Aggregates only: no VAT numbers.
Optional client field
You can identify your integration with client, format <name>/<version>, for example shopware-plugin/0.1: lowercase letters, digits, ., _ and - in the name, the version starts with a digit, at most 48 characters. Send it in the JSON body or as a query parameter of /api/v0/validate (and as an argument of the MCP tool). An invalid value returns HTTP 400. It is used only to count adoption per integration.
Optional proof-of-work on POST /api/v0/keys
Without proof-of-work, at most 3 keys per day are issued per IP address (per /64 network for IPv6). Shops on shared hosting can get up to 20 keys per day from the same address by sending a proof-of-work in the X-Keys-PoW header, value <unix_ts>.<nonce>. It is valid if sha256(aether-keys-pow-v1|<ts>|<nonce>) starts with 20 zero bits, ts is within 300 seconds of the server clock, and the value has not been used before. The daily issuance cap of the service still applies.
Computing the value in JavaScript (Node.js), then using it and the client field with curl:
import { createHash } from "node:crypto";
// 20 leading zero bits of sha256("aether-keys-pow-v1|<ts>|<nonce>"): about one million hashes.
const ts = Math.floor(Date.now() / 1000);
let nonce = 0;
for (;; nonce++) {
const h = createHash("sha256").update(`aether-keys-pow-v1|${ts}|${nonce}`).digest();
if (h[0] === 0 && h[1] === 0 && h[2] < 16) break;
}
console.log(`${ts}.${nonce}`); // value of the X-Keys-PoW header, valid for 300 s, usable once
curl -sS -X POST https://aether-layer.com/api/v0/keys \
-H 'content-type: application/json' \
-H "X-Keys-PoW: $POW" \
--data '{}'
curl -sS -X POST https://aether-layer.com/api/v0/validate \
-H "Authorization: Bearer $API_KEY" \
-H 'content-type: application/json' \
--data '{"vat_id":"DE143454214","client":"shopware-plugin/0.1"}'
MCP
The same key authorizes validate_vat (arguments vat_id, optional country and client) and agent_readiness_check on the Streamable HTTP endpoint. tools/list needs no key. Name in the MCP Registry: com.aether-layer/eu-vat-validate.
{
"mcpServers": {
"aether-layer": {
"url": "https://aether-layer.com/api/mcp",
"headers": {
"Authorization": "Bearer $API_KEY"
}
}
}
}
Limits
- 20 validate_vat requests per key per Europe/Rome day.
- VIES is queried at a moderate pace per instance: at most 2 requests in flight per member state and 6 in total; further requests wait up to 1.5 s in a short queue, then get
unverified. Under load you getunverified_ms_downorunverified, never a made-up result. - No payment is taken on this endpoint.
- VAT numbers are not stored in plaintext: the usage log keeps only a keyed hash (HMAC). See the privacy notice and the VIES privacy statement (European Commission, English).
Licences
The format and checksum rules are a TypeScript port of python-stdnum (Copyright (C) 2010-2026 Arthur de Jong and others), distributed under the GNU LGPL, version 2.1 or later (LGPL-2.1-or-later, licence text). The port is a separate module of the gateway with its own NOTICE.