vatverify home
All guides

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/batch accepts 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_limited responses. 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:

  • tag round-trips unchanged. Use it to map a batch result back to your input row without re-parsing the VAT number.
  • summary is 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.

batch-validate.ts
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 (typically MS_UNAVAILABLE from 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_number and tag (your input row identifier)
  • valid
  • country
  • consultation_number (when present, the proof-of-query reference issued by VIES)
  • The meta.fetched_at timestamp 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/decide in 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/decide per cross-border B2B row.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free