How to Bulk Re-Validate Customer VAT IDs

31 July 2026

How to Bulk Re-Validate Customer VAT IDs

How to Bulk Re-Validate Your Customer VAT IDs (and Keep Them Fresh)

Most teams validate a customer's VAT number once — at signup, or the first time they issue a reverse-charge invoice — and then never look at it again. That is fine until an auditor asks whether the number was still valid at the time of each supply, or until a customer who deregistered eighteen months ago is still being invoiced without VAT. A VAT registration is not a permanent fact. This post is the practical version of fixing that: how to validate a whole existing customer base in one backfill pass, survive VIES going down partway through, spot the numbers that quietly flipped from valid to invalid, and then stop doing it by hand.

The scope here is the EU-27 plus Northern Ireland (XI), which is what VIES covers. Everything below uses the vatnode VAT API — a single GET /v1/vat/:vatId call with a Bearer key — but the batching and requeue logic applies to any VIES client you build yourself.

Why a stored VAT ID goes stale

A number that returned valid last year can return invalid today. Businesses deregister, get struck off, restructure into a new entity, or have their intra-EU (VIES) registration disabled while the domestic registration lives on. None of that generates a notification. The value sitting in your customers table is a snapshot of a moment that has already passed.

This matters because the customer's valid VAT ID is a substantive condition for zero-rating intra-Community supplies of goods — Article 138 of the EU VAT Directive (2006/112/EC), as amended by the 2020 Quick Fixes. For goods, the linked condition is the valid VAT ID plus a correct recapitulative statement (your EC Sales List). "Reverse charge" in the strict sense is the services case. Either way, the defensible position is the same: you want timestamped evidence that the condition held at the time of each supply, not just at signup.

There is no single EU-mandated re-check frequency. Cadence is a risk decision — set it with your tax adviser. But the mechanism for keeping the evidence current is the same regardless of the interval you land on, and that mechanism is what the rest of this post builds.

This is general information about EU VAT and VIES, not tax advice. Whether a specific transaction qualifies for zero-rating, exemption, or reverse charge depends on facts we can't assess here — confirm the treatment of your own transactions with a qualified tax adviser.

Step 1 — dedupe and format-filter before you call anything

The first pass never touches the network. A real customer base has the same VAT ID stored against multiple contacts, the same number written five different ways (DE123456789, DE 123 456 789, de123456789), and a long tail of junk that will never validate — blank strings, phone numbers pasted into the wrong field, national tax numbers that are not VIES-format at all.

Every one of those is a wasted VIES call if you send it. Normalise first, dedupe second, then discard anything that cannot be a well-formed EU/XI VAT ID before you spend a single request:

type CustomerRow = { customerId: string; rawVatId: string }

// Uppercase, strip everything that isn't a letter or digit.
function normalizeVatId(raw: string): string {
  return raw.toUpperCase().replace(/[^A-Z0-9]/g, '')
}

// Cheap structural gate: two-letter country prefix + at least one alphanumeric.
// This is a coarse filter, NOT full per-country validation — the API does that.
const VAT_SHAPE = /^[A-Z]{2}[A-Z0-9]{2,}$/

function prepareBatch(rows: CustomerRow[]) {
  const byVatId = new Map<string, string[]>() // vatId -> customerIds
  const skipped: CustomerRow[] = []

  for (const row of rows) {
    const vatId = normalizeVatId(row.rawVatId)
    if (!VAT_SHAPE.test(vatId)) {
      skipped.push(row) // malformed — flag for manual review, don't call VIES
      continue
    }
    const ids = byVatId.get(vatId) ?? []
    ids.push(row.customerId)
    byVatId.set(vatId, ids)
  }

  // One network call per distinct number; fan the result back out to customers.
  const toCheck = [...byVatId.entries()].map(([vatId, customerIds]) => ({
    vatId,
    customerIds,
  }))

  return { toCheck, skipped }
}

On a base of any real size this collapses the work dramatically — it is common to see tens of thousands of customer rows resolve to a few thousand distinct numbers. You validate each distinct number once and fan the answer back out to every customer that shares it. The skipped list is not garbage; it is a work queue for a human, because a malformed stored VAT ID is often a data-entry bug worth fixing at the source.

Step 2 — batch the base at a sane throughput

VIES is not one service. It is a thin EC gateway in front of national tax-authority nodes, and those nodes are per-country and rate-sensitive. Firing ten thousand requests in parallel is the fastest way to get throttled and turn a clean backfill into a mess of transient failures. The goal is steady, bounded throughput — a small concurrency limit, running continuously — not a thundering herd.

A simple worker-pool pattern keeps a fixed number of checks in flight and refills as each one finishes:

const API = 'https://api.vatnode.dev/v1/vat'
const KEY = process.env.VATNODE_API_KEY

type CheckJob = { vatId: string; customerIds: string[] }
type Outcome =
  | { vatId: string; status: 'checked'; data: VatResult }
  | { vatId: string; status: 'requeue'; reason: string }

async function runBatch(jobs: CheckJob[], concurrency = 4): Promise<Outcome[]> {
  const queue = [...jobs]
  const results: Outcome[] = []

  async function worker() {
    let job: CheckJob | undefined
    while ((job = queue.shift())) {
      results.push(await checkOne(job))
    }
  }

  // A handful of workers, not thousands of parallel fetches.
  await Promise.all(Array.from({ length: concurrency }, () => worker()))
  return results
}

Keep the concurrency low — a handful of workers is plenty for a background job that is allowed to take minutes or hours. There is no prize for finishing a backfill in ten seconds, and being polite to the national nodes is what keeps your error rate near zero. If you are running your own VIES client rather than the VAT API, the same discipline applies, plus you own the caching and retry layer that the API otherwise handles for you — see re-validation cadence and TTLs for how to think about that.

Step 3 — surviving a VIES outage mid-run

This is the step people get wrong, and it is the one that does real damage. Partway through a long backfill, a country node will go down. Those rows cannot be confirmed in this run — and that is all it means. A could-not-check outcome is not a negative result. If you store VIES_UNAVAILABLE or a timeout as valid: false, you have just marked a batch of real, paying customers as having invalid VAT numbers, and someone downstream will act on it.

The rule is simple: only valid: true and valid: false are real outcomes. Everything else — VIES_UNAVAILABLE (503), UPSTREAM_TIMEOUT (504), RATE_LIMITED (429) — is pending. Requeue it and try again later. Never let it touch the stored status.

type VatResult = {
  valid: boolean
  vatId: string
  countryCode: string
  countryName: string
  companyName: string | null
  companyAddress: string | null
  checkId: string
  verifiedAt: string
  source: string // 'VIES' | 'CACHE' | national registry code
  consultationNumber: string | null
}

async function checkOne(job: CheckJob): Promise<Outcome> {
  let res: Response
  try {
    res = await fetch(`${API}/${encodeURIComponent(job.vatId)}`, {
      headers: { Authorization: `Bearer ${KEY}` },
    })
  } catch {
    // Network/transport error — temporary, requeue.
    return { vatId: job.vatId, status: 'requeue', reason: 'NETWORK' }
  }

  // A node being down is not the customer's fault. Requeue, never store invalid.
  if (res.status === 429 || res.status === 503 || res.status === 504) {
    return { vatId: job.vatId, status: 'requeue', reason: `HTTP_${res.status}` }
  }

  if (!res.ok) {
    // Genuine client errors (malformed input etc.) — surface, don't loop forever.
    return { vatId: job.vatId, status: 'requeue', reason: `HTTP_${res.status}` }
  }

  const data = (await res.json()) as VatResult
  // Only here do we have a real valid/invalid answer worth persisting.
  return { vatId: job.vatId, status: 'checked', data }
}

Run the requeue list as a second pass after a backoff — a down node is usually back within minutes to hours. Because VIES failures are country-scoped, a single unreachable member state should not stall the rest of the run; the healthy nodes keep answering while the affected prefix cools off. The full retry-and-backoff pattern, including per-country failure tracking, is in the handling VIES downtime guide. The one non-negotiable: a pending row keeps its previous stored status until a real answer replaces it.

Step 4 — recording what changed (and what to do about it)

A backfill that overwrites the old value with the new one and moves on has thrown away the interesting part. The point of re-validation is the diff — which numbers flipped since the last time you checked. Compare each fresh result against the last stored outcome and act only on the transitions:

type StoredOutcome = {
  customerId: string
  vatId: string
  valid: boolean
  source: string
  consultationNumber: string | null
  verifiedAt: string
}

function diffOutcome(
  previous: StoredOutcome,
  fresh: VatResult
): 'unchanged' | 'went_invalid' | 'became_valid' {
  if (previous.valid === fresh.valid) return 'unchanged'
  return fresh.valid ? 'became_valid' : 'went_invalid'
}

// Persist an APPEND-ONLY row for every real check — never mutate the old one.
async function recordCheck(customerId: string, fresh: VatResult) {
  await db.insert('vat_checks', {
    customerId,
    vatId: fresh.vatId,
    valid: fresh.valid,
    source: fresh.source, // 'VIES' vs a national registry code — keep it
    consultationNumber: fresh.consultationNumber, // null on national-fallback rows
    checkId: fresh.checkId,
    verifiedAt: fresh.verifiedAt,
  })
}

A few things hold up in practice here. Store an append-only row per check, never an overwrite — the timestamped history is the audit trail. Keep the source on every row: a VIES result and a national-registry result are not equivalent evidence. National-fallback rows carry no consultationNumber — that field is a VIES-only artefact — so do not treat a populated and a null consultation number as the same weight. When source is VIES, the consultation number is your externally-attested evidence for each re-check; when the answer came from a national registry, you still have a timestamped result, just of a different kind.

The went_invalid list is your action queue. Those are the customers to stop reverse-charging until the number is fixed — but do it as a quiet outreach, not an automatic billing change. A flip to invalid can mean a genuine deregistration or a customer who restructured and simply needs to give you the new number.

Step 5 — from one-off backfill to continuous monitoring

Steps 1–4 are a batch job you run once to clean up the base you already have. The obvious next question is how often to run it again — and the better answer is usually "don't run it by hand at all". A backfill is the one-time version of a loop that wants to be continuous: re-check on a cadence, diff against the last stored outcome, and get told when something flips.

That is exactly what monitoring is. Instead of scheduling a manual bulk pass, you register the numbers you care about and let re-checks run automatically, with a webhook firing when a number's status changes so the went_invalid case lands in your systems the day it happens rather than at your next quarterly sweep. The setup and payload shape are in the VAT monitoring and webhooks guide.

The practical split: run the bulk backfill once to establish a clean baseline and catch everything that already went stale, then hand the ongoing job to monitoring so you never have to schedule the manual cadence again. Bulk re-validation answers "is my existing base clean right now?"; monitoring answers "tell me the moment it stops being clean."

FAQ

How often should I re-validate stored VAT numbers?

There's no legal fixed interval, but because registrations get cancelled, re-checking on a periodic cadence (and before high-value events like renewals or invoicing) keeps your data defensible; continuous monitoring removes the manual cadence entirely.

Can I validate thousands of VAT numbers at once?

Yes, but pace the run — dedupe and format-filter first to avoid wasted calls, then batch at a steady throughput rather than firing everything in parallel, since the underlying VIES nodes are per-country and rate-sensitive.

What happens if VIES goes down partway through a bulk run?

Treat unreachable results as pending, not invalid, and requeue them; a per-country node being offline is temporary and should not mark real customers as failed.

How do I know which customers' VAT IDs went invalid?

Compare each re-check against the last stored outcome and flag only the numbers whose status flipped, keeping the timestamp and evidence for each so the change is auditable.

Is bulk re-validation different from monitoring?

Bulk re-validation is a one-time backfill of your existing base; monitoring is the ongoing version that re-checks automatically and notifies you (via webhooks) when a number changes.

Validate your whole base against one endpoint

vatnode is a single GET /v1/vat/:vatId call with a Bearer key — it runs the requester-qualified VIES lookup, falls back to national registries when a node is down, and returns structured errors so your requeue logic works exactly as above. Free plan, 100 requests/month.

Get a free API key · API reference