EU VAT validation API

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

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

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.