Validate EU VAT numbers in bulk from a CSV: the loop, the pacing, the evidence
To validate a list of EU VAT numbers, do not paste them into VIES one by one and do not fire them at it in parallel: normalise and de-duplicate the list, drop the numbers whose structure or check digit is impossible (no call needed), then validate the rest one at a time per member state, writing one dated line per number with its provenance (live, cache and stale, or not_called) and, on a paid tier, the VIES consultation number; queue the numbers whose member state is down and retry them when the VIES status page shows it back. The two scripts below do exactly that against GET https://veriport.io/api/v1/vat/validate (20 calls a day without a key, 100 a month with a free one); the monthly plan adds a bulk endpoint of 100 numbers a request.
- VIES has no bulk endpoint and refuses concurrency (
MS_MAX_CONCURRENT_REQ,GLOBAL_MAX_CONCURRENT_REQ): a batch is a paced loop, whoever runs it. The Commission blocks addresses that hammer the service. - Impossible numbers cost nothing: a wrong structure or check digit answers 200 with
valid: falseandupstream.status: "not_called", uncounted; in a typical customer file that is 3 to 8 % of the rows, and they never reach VIES. - A number validated in the last 30 days whose member state is down is answered from the cache, dated and marked
stale; a number never seen gets a 503 that is not counted: queue it, never write « invalid ». - Throughput: 2 requests a second on the free key (10,000 numbers in about 85 minutes), 10 a second and 100 numbers a request on the plan; VIES itself answers in one to three seconds per number, slower at month-end.
Before the first call: clean the list
Upper-case, strip spaces, dots and dashes, keep the two-letter prefix (Greece is EL; a GR prefix is normalised; a GB number is not in VIES since 2021 and goes to HMRC instead). De-duplicate: customer files repeat numbers across invoices, and VIES counts each query. Then check the structure offline: the national pattern and, for the 28 formats, the public check digit. The TypeScript and Python SDKs ship this check as checkVatStructure() under MIT, no network; without the SDK, the API does it for free on the first call and answers not_called.
The loop: one request at a time, the answer kept whole
One GET per number, the key optional for the first calls, your own VAT number as requester on a paid tier for the consultation number. The result file keeps what an audit asks for: the normalised number, the verdict, the registered name, the date VIES stamped, where the answer came from and whether it was stale, the consultation number, and the upstream status. The script writes retry.csv with the numbers that got no answer.
import { readFile, writeFile } from "node:fs/promises";
const API = "https://veriport.io/api/v1";
const KEY = process.env.VERIPORT_KEY; // optional: 20 calls a day without one, 100 a month with a free key
const REQUESTER = process.env.MY_VAT_NUMBER; // your own VAT number: consultation numbers on the paid tiers
const headers = { Accept: "application/json", ...(KEY ? { Authorization: `Bearer ${KEY}` } : {}) };
// 1. Read, normalise, de-duplicate: one column, one VAT number per line, prefix included.
const raw = (await readFile("numbers.csv", "utf8")).split(/\r?\n/).map((l) => l.split(",")[0].trim()).filter(Boolean);
const numbers = [...new Set(raw.map((n) => n.toUpperCase().replace(/[\s.\-]/g, "")))];
const rows = [["number", "valid", "name", "requestDate", "source", "stale", "consultationNumber", "status"]];
const retry = [];
for (const n of numbers) {
const url = new URL(`${API}/vat/validate`);
url.searchParams.set("number", n);
if (REQUESTER) url.searchParams.set("requester", REQUESTER);
const res = await fetch(url, { headers });
const a = await res.json();
if (res.ok) {
// 2. One line per number, the provenance kept: a cached answer is evidence of its own date, not today's.
rows.push([a.full, a.valid, a.name ?? "", a.requestDate ?? "", a.source.kind, a.source.stale, a.consultationNumber ?? "", a.upstream.status]);
} else if (a.code === "vies_unavailable" || a.code === "vies_blocked") {
retry.push(n); // 3. the member state or VIES is down and no cached answer exists: queue, never write "invalid"
await new Promise((r) => setTimeout(r, (a.retryAfter ?? 30) * 1000));
} else if (a.code === "rate_limited") {
await new Promise((r) => setTimeout(r, Number(res.headers.get("Retry-After") ?? 1) * 1000));
numbers.push(n); // back of the queue
} else {
rows.push([n, "", "", "", "", "", "", a.code]); // invalid_parameter, quota_exceeded… the code says why
}
// 4. Pace: the free key allows 2 requests a second; VIES itself asks for one in flight per member state.
await new Promise((r) => setTimeout(r, 550));
}
await writeFile("results.csv", rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n") + "\n");
await writeFile("retry.csv", retry.join("\n") + "\n");
console.log(`${rows.length - 1} answered, ${retry.length} to retry (see /vies-status for the member states that are down)`);import csv, os, time, requests
API = "https://veriport.io/api/v1"
HEADERS = {"Accept": "application/json"}
if os.environ.get("VERIPORT_KEY"):
HEADERS["Authorization"] = f"Bearer {os.environ['VERIPORT_KEY']}"
REQUESTER = os.environ.get("MY_VAT_NUMBER") # consultation numbers on the paid tiers
with open("numbers.csv", newline="", encoding="utf-8") as f:
seen, numbers = set(), []
for row in csv.reader(f):
n = row[0].strip().upper().replace(" ", "").replace(".", "").replace("-", "") if row else ""
if n and n not in seen:
seen.add(n); numbers.append(n)
out = csv.writer(open("results.csv", "w", newline="", encoding="utf-8"))
out.writerow(["number", "valid", "name", "requestDate", "source", "stale", "consultationNumber", "status"])
retry = []
for n in numbers:
params = {"number": n, **({"requester": REQUESTER} if REQUESTER else {})}
r = requests.get(f"{API}/vat/validate", params=params, headers=HEADERS, timeout=30)
a = r.json()
if r.ok:
out.writerow([a["full"], a["valid"], a.get("name") or "", a.get("requestDate") or "", a["source"]["kind"], a["source"]["stale"], a.get("consultationNumber") or "", a["upstream"]["status"]])
elif a.get("code") in ("vies_unavailable", "vies_blocked"):
retry.append(n); time.sleep(a.get("retryAfter") or 30)
elif a.get("code") == "rate_limited":
time.sleep(int(r.headers.get("Retry-After", "1"))); numbers.append(n)
else:
out.writerow([n, "", "", "", "", "", "", a.get("code")])
time.sleep(0.55) # 2 requests a second on the free key
open("retry.csv", "w").write("\n".join(retry) + "\n")
print(f"{len(numbers) - len(retry)} answered, {len(retry)} to retry")On the monthly plan: the bulk endpoint
POST /vat/validate/bulk takes up to 100 numbers and answers them in order, each slot either the same answer as the single endpoint or a problem document for that number (a member state down for one number does not fail the others); each answered item counts one call. The requester applies to every item. Chunk a 10,000-line file into 100 requests and run them sequentially: the API paces VIES per member state on its side.
curl -X POST "https://veriport.io/api/v1/vat/validate/bulk" \
-H "Authorization: Bearer vp_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"requester":"FR40303265045","items":[{"number":"DE136695976"},{"number":"FR40303265045"},{"country":"IT","number":"00743110157"}]}'Which rows failed, and what to do with them
| In the results file | Meaning | Action |
|---|---|---|
valid true, source live | VIES answered now. | Done; keep the line (and the consultation number) with the customer record. |
valid true, source cache, stale true | The member state is down; this is the answer of requestDate, from the last 30 days. | Accept for routine invoicing; re-run the number when the state is back for onboarding or large amounts. |
valid false, status ok | VIES says not registered for intra-EU trade. | Charge VAT or ask the customer for the right number; a domestic-only registration (ES, IT) is the common case. |
valid false, status not_called | The structure or check digit is impossible. | A typo in your file: fix at the source. |
In retry.csv | VIES or the member state did not answer and no cached answer exists. | Re-run when the status page shows the state back; nothing was counted. |
status quota_exceeded / forbidden_for_tier | The month’s allowance is used, or requester on the free tier. | Continue next month, add a pack, or drop requester. |
How often should a customer file be re-validated?
Registrations end (liquidations, deregistrations after inactivity, group changes) and tax administrations expect the check to be reasonably current for recurring intra-EU supplies. A quarterly run over the active customers, plus a check at each onboarding, is the common practice; keep each run’s results file dated. Numbers that were valid last quarter and are not this quarter are the rows to act on before the next invoice.
Questions
- Can I upload the CSV instead of running a script?
- Not in v1: the validation endpoints take JSON (one number, or up to 100 on the plan). The OSS return endpoint accepts a CSV because that is how sales exports come; a CSV upload for validation is on the roadmap and will be announced in the changelog.
- How long does 10,000 numbers take?
- About 85 minutes at the free key’s 2 requests a second if VIES answers quickly, longer at month-end when member states slow down; on the plan, 100 bulk requests of 100 numbers, each answered in the time its slowest member state takes. VIES itself is the clock: it serialises per member state.
- Why not run the loop in parallel to go faster?
- Because VIES refuses concurrent requests for the same member state (
MS_MAX_CONCURRENT_REQ) and blocks addresses that keep trying (IP_BLOCKED). A parallel loop is slower in the end and can get an address blocked for everyone behind it; the API paces upstream calls per member state for this reason. - Is the results file enough as evidence?
- With the date, the verdict, the provenance and, on the paid tiers, the consultation number per line: yes, it is the record tax administrations ask for. Keep it with the period’s invoices; a cached line is evidence of its own
requestDate.
100 validations a month, no card; packs and the plan for the real runs.
Get a free keySources
- VIES REST API — check-vat-number, check-status (technical information) — European Commission, DG TAXUD, consulted 2026-10-24
- VIES — help page: what the consultation number proves, member-state availability — European Commission, DG TAXUD, consulted 2026-10-24
- VIES — VAT Information Exchange System: FAQ and VAT number structures (Q11) — European Commission, DG TAXUD, consulted 2026-10-24