Batch VAT validation: validate up to 50 numbers per request
Validate hundreds or thousands of VAT numbers at once with the batch endpoint. Concurrency, retries, partial-failure handling, and the patterns that scale to weekly customer-list audits.
TL;DR
/v1/validate/batchaccepts up to 50 VAT numbers in a single request and returns one result per number.- Each number is validated against its country's registry independently, so partial failures are normal and explicit in the response shape.
- For larger lists (thousands of customers), batch the input client-side, parallelise across batches, and back off on
rate_limitedresponses. The pattern below does all three.
When to use batch instead of single-number calls
Single-number /v1/validate is the right call for live-checkout, customer-onboarding, and any flow where one VAT number resolves at a time. Batch is right when:
- You audit your full B2B customer list once a month and want to spot deregistrations.
- You import a CSV from a CRM or accounting tool and need to clean up VAT numbers before they hit Stripe Tax or your invoicing system.
- You build a one-off compliance report for the finance team across an entire customer base.
Batch is gated to the Pro and Business plans. The Free and Starter plans receive plan_required on /v1/validate/batch. Test-mode keys (vtv_test_) bypass plan gating and are the right choice for local development.
The request and response shape
A batch request takes an array of objects, each with a vat_number and an optional tag you can use to correlate results with your own input row.
curl https://api.vatverify.dev/v1/validate/batch \
-H "Authorization: Bearer $VATVERIFY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"items": [
{ "vat_number": "DE123456789", "tag": "row-1" },
{ "vat_number": "FR12345678901", "tag": "row-2" },
{ "vat_number": "GB123456789", "tag": "row-3" }
]
}'The response includes one entry per input, in the same order:
{
"data": {
"items": [
{ "tag": "row-1", "valid": true, "country": "DE", "company": null, "consultation_number": "WAPIAAAA1234..." },
{ "tag": "row-2", "valid": true, "country": "FR", "company": { "name": "..." }, "consultation_number": "..." },
{ "tag": "row-3", "valid": false, "country": "GB", "reason": "deregistered" }
],
"summary": { "total": 3, "valid": 2, "invalid": 1, "errored": 0 }
},
"meta": {
"request_id": "...",
"latency_ms": 1842
}
}Two fields worth noting:
taground-trips unchanged. Use it to map a batch result back to your input row without re-parsing the VAT number.summaryis computed server-side. It splits results into valid, invalid, and errored. Errored rows are ones where the upstream registry returned a transient failure (registry_unavailable,MS_UNAVAILABLE); they should be retried.
A scaling pattern for thousands of numbers
Most use cases need to validate more than 50 numbers at once. The basic pattern: split the input into batches of 50, run a small concurrency pool, and retry errored items.
import { Vatverify } from '@vatverify/node';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
const BATCH_SIZE = 50;
const CONCURRENCY = 4;
interface InputRow {
id: string;
vat_number: string;
}
interface ResultRow extends InputRow {
valid: boolean | null;
country: string | null;
reason: string | null;
consultation_number: string | null;
}
export async function validateAll(rows: InputRow[]): Promise<ResultRow[]> {
const batches: InputRow[][] = [];
for (let i = 0; i < rows.length; i += BATCH_SIZE) {
batches.push(rows.slice(i, i + BATCH_SIZE));
}
const results: ResultRow[] = [];
for (let i = 0; i < batches.length; i += CONCURRENCY) {
const window = batches.slice(i, i + CONCURRENCY);
const settled = await Promise.all(window.map(runBatch));
results.push(...settled.flat());
}
return results;
}
async function runBatch(batch: InputRow[]): Promise<ResultRow[]> {
const items = batch.map((r) => ({ vat_number: r.vat_number, tag: r.id }));
const res = await vat.validateBatch({ items });
return res.data.items.map((item, i) => ({
id: batch[i]!.id,
vat_number: batch[i]!.vat_number,
valid: typeof item.valid === 'boolean' ? item.valid : null,
country: item.country ?? null,
reason: item.reason ?? null,
consultation_number: item.consultation_number ?? null,
}));
}The concurrency cap of 4 is a starting point. Each batch consumes one HTTP request and (under the hood) up to 50 upstream registry calls. Pushing concurrency higher risks tripping the per-key RPM limit (90 req/min on Pro, 180 on Business) and is rarely necessary because individual batches are slower than the rate budget anyway.
Handling partial failures
A batch with one bad number does not fail the whole request. The response item carries either valid: true/false or an error code:
{
"tag": "row-7",
"valid": null,
"error": { "code": "registry_unavailable", "message": "FR registry returned MS_UNAVAILABLE" }
}Three error codes you will see most often:
invalid_format: the input did not match the country's VAT format. No registry call was made; no retry will help. Surface to the user as a data-quality problem.registry_unavailable: the upstream country registry returned a transient fault (typicallyMS_UNAVAILABLEfrom VIES). Retry after a short delay; most of these resolve within minutes.country_unsupported: the VAT number's country is not in the supported set. No retry will help; the caller has to handle the unsupported jurisdiction differently.
The retry pattern that works:
async function validateAllWithRetry(rows: InputRow[]) {
const first = await validateAll(rows);
const transientFails = first.filter((r) => r.reason === 'registry_unavailable');
if (transientFails.length === 0) return first;
await sleep(60_000);
const retried = await validateAll(transientFails);
return mergeResults(first, retried);
}A 60-second delay before retry is enough for the typical VIES MS_UNAVAILABLE window. For more persistent country-specific outages, surface the error to the caller and let them decide; do not retry indefinitely.
Caching and quota economics
Cached responses do not count against batch quota in the same way as live calls. If your customer list contains a number that vatverify validated for any caller within the last 30 days (the default valid TTL), the response comes from cache and uses zero registry-side budget.
In practice, a monthly customer-list audit hits cache heavily on the second run because most numbers have not changed. A 5,000-customer list might use only a few hundred live calls on the second pass instead of 5,000.
Storing the audit trail
Every batch response carries enough information to be the audit-trail record. The fields that matter:
vat_numberandtag(your input row identifier)validcountryconsultation_number(when present, the proof-of-query reference issued by VIES)- The
meta.fetched_attimestamp from the response wrapper
vatverify retains its own audit log on Pro and Business plans; the Pro plan retains 30 days, Business retains 90. If you run an annual audit, persist the response payload yourself in your accounting system as well; that gives you a reconstructible record beyond the platform retention.
What this tutorial does not cover
- Webhooks. The batch endpoint is synchronous; there is no async-job pattern. For very large lists where the response time exceeds your timeout budget, split the work into smaller jobs and use webhooks to receive per-job summaries. See the VAT webhooks guide for that pattern.
/v1/decidein batch. The tax-rules engine is currently single-call only. For B2B reverse-charge decisions across a large customer list, validate first with batch, then call/v1/decideper cross-border B2B row.