# Validate EU VAT numbers in bulk from a CSV: the loop, the pacing, the evidence

Source: https://veriport.io/guides/validate-eu-vat-numbers-in-bulk-from-csv (published 2026-10-28)

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](/vies-status) 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: false` and `upstream.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.

```javascript
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)`);
```

```python
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.

```bash
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](/vies-status) 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`.

## Read next

- [VIES API: REST vs SOAP, errors and limits](https://veriport.io/guides/vies-api-check-eu-vat-number)
- [VIES is down: validate anyway](https://veriport.io/guides/vies-down-validate-eu-vat-number-anyway)
- [Is VIES down? Availability by member state](https://veriport.io/vies-status)
- [Reference: GET /vat/validate and the bulk endpoint](https://veriport.io/docs/vat-validation)
- [EU VAT number validation API (VIES)](https://veriport.io/vat-validation-api)

## Sources

- VIES REST API — check-vat-number, check-status (technical information) — https://ec.europa.eu/taxation_customs/vies/#/technical-information (2026-10-24)
- VIES — help page: what the consultation number proves, member-state availability — https://ec.europa.eu/taxation_customs/vies/#/help (2026-10-24)
- VIES — VAT Information Exchange System: FAQ and VAT number structures (Q11) — https://ec.europa.eu/taxation_customs/vies/#/faq (2026-10-24)
