How to Bulk Re-Validate Customer VAT IDs
31 July 2026

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: 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, then stop doing it by hand.
The scope here is the EU-27 plus Northern Ireland (XI) – what VIES covers. The code below uses the vatnode VAT API: GET /v1/vat/:vatId for a single number, and the asynchronous bulk endpoint (POST /v1/vat/bulk) to revalidate a whole base in one job. The dedupe and outage-handling logic apply 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. The mechanism for keeping the evidence current is the same regardless of the interval you land on.
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 submit 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.
The bulk endpoint de-duplicates on intake and bills a repeated number once, so this step is not about avoiding a wasted call. It still earns its place, for two other reasons.
A job’s results come back keyed by vatId and position, not by customer. Fanning an answer back out to every contact that shares a number is still entirely your job, and the vatId → customerId[] map below is what that takes.
A malformed number still occupies a position. It costs no quota, but it comes back as an INVALID_FORMAT entry cluttering your results. Catching it here keeps the skipped list separate from the numbers actually worth submitting:
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 submit it
continue
}
const ids = byVatId.get(vatId) ?? []
ids.push(row.customerId)
byVatId.set(vatId, ids)
}
// One position 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: tens of thousands of customer rows commonly resolve to a few thousand distinct numbers. Submit toCheck.map(t => t.vatId) as the batch and keep the byVatId map on your side; it is the only place that link exists once the job comes back. 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 – why batching still matters, even though you don’t write it
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. That is true for anyone building their own VIES client: the goal is steady, bounded throughput running continuously, not a thundering herd.
Through vatnode’s bulk endpoint you write none of this. A submitted job runs five checks at a time whatever the batch size, and steps aside from a member state VIES is currently struggling with, retrying it later instead of hammering it. That bound is the whole mechanism: there is no priority queue putting your live GET /v1/vat/:vatId calls ahead of your own backfill, just a background job that cannot grow into a thundering herd. Run your own VIES client instead and the same discipline applies, plus you own the caching and retry layer 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 job, a member state’s node will go down. The job handles that part on its own: it steps aside from the struggling country and keeps working the rest of the batch. What it cannot do is stop you from misreading the result.
A position’s valid field is boolean | null – true or false once VIES gave a real answer, null while the position is still pending or whenever it failed. If your reconciliation code does if (!position.valid) markInvalid(...), JavaScript treats null exactly like false, and you have just marked a batch of real, paying customers as having invalid VAT numbers because a node was offline for twenty minutes.
A failed position always carries an error object alongside valid: null – VIES_UNAVAILABLE or UPSTREAM_TIMEOUT when the node itself is unreachable, RATE_LIMITED once your monthly quota runs out partway through. None of this stops the job: status still reaches finished once every position has an answer, including the ones whose answer is an error. finished is a claim about coverage, not about validity – read summary.errors, not just summary.valid and summary.invalid.
for (const position of positions) {
if (position.error) {
if (position.error.code === 'INVALID_FORMAT') {
manualReview.push(position.vatId) // Step 1's problem, not VIES's — fix at the source
} else {
// VIES_UNAVAILABLE, UPSTREAM_TIMEOUT, RATE_LIMITED, AUDIT_WRITE_FAILED — not the number's fault.
resubmit.push(position.vatId)
}
continue // either way, do NOT touch the customer's stored status
}
// Only here — no error — is `valid` a real true/false worth persisting.
recordCheck(position)
}
Requeueing here means resubmitting resubmit as a fresh POST /v1/vat/bulk job once the affected node recovers. There is no per-request retry to write, because there was never a per-request call to retry. The docs prescribe the same for RATE_LIMITED (after upgrading or the quota resetting) and for AUDIT_WRITE_FAILED (the check ran but its record failed to save, so nothing was billed and it’s safe to resubmit). VIES failures are country-scoped, so one unreachable member state does not stall the whole job – the healthy nodes keep answering while the affected prefix cools off. The full retry-and-backoff pattern for a client you build yourself, including per-country failure tracking, is in the handling VIES downtime guide. The one non-negotiable: a position without a real verdict keeps its previously stored status until a real answer replaces it.
The actual flow: submit, poll, read results
Steps 1–3 put together, a run against the real endpoints looks like this (full request/response shapes and the error taxonomy are in the bulk endpoint reference):
const API = 'https://api.vatnode.dev/v1/vat/bulk'
const KEY = process.env.VATNODE_API_KEY
type BulkJob = {
jobId: string
status: 'queued' | 'processing' | 'finished' | 'cancelled' | 'failed'
totalItems: number
processedItems: number
summary: { valid: number; invalid: number; errors: number }
}
type BulkPosition = {
position: number
vatId: string
status: 'pending' | 'done'
valid: boolean | null
source: string | null
consultationNumber: string | null
checkId: string | null
error: { code: string; message: string | null } | null
processedAt: string | null
}
// The Idempotency-Key belongs to the BATCH, not to the attempt: mint it once,
// resend it on every retry. Without it a submit that times out before the 202
// arrives leaves you unable to tell whether the batch was accepted, and a
// retry buys the whole thing a second time.
async function submitJob(vatIds: string[], idempotencyKey: string) {
const res = await fetch(API, {
method: 'POST',
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({ vatIds }),
})
if (res.status !== 202) throw new Error(`submit failed: HTTP ${res.status}`)
return res.json() as Promise<{ jobId: string; totalItems: number; duplicatesDropped: number }>
}
async function waitForJob(jobId: string, intervalMs = 5000): Promise<BulkJob> {
for (;;) {
const res = await fetch(`${API}/${jobId}`, { headers: { Authorization: `Bearer ${KEY}` } })
const job = (await res.json()) as BulkJob
if (job.status === 'finished' || job.status === 'cancelled' || job.status === 'failed') {
return job
}
await new Promise((resolve) => setTimeout(resolve, intervalMs))
}
}
async function* pageResults(jobId: string, limit = 100) {
for (let page = 1; ; page++) {
const res = await fetch(`${API}/${jobId}/results?page=${page}&limit=${limit}`, {
headers: { Authorization: `Bearer ${KEY}` },
})
const { items, pages } = (await res.json()) as { items: BulkPosition[]; pages: number }
yield* items
if (page >= pages) return
}
}
async function cancelJob(jobId: string) {
await fetch(`${API}/${jobId}/cancel`, {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}` },
})
}
Wired up against prepareBatch‘s output from Step 1:
const { toCheck } = prepareBatch(customerRows)
const byVatId = new Map(toCheck.map((t) => [t.vatId, t.customerIds]))
// Persist this alongside the run, don't generate it inside a retry loop —
// that is the difference between one job and two.
const runId = crypto.randomUUID()
const { jobId, duplicatesDropped } = await submitJob(
toCheck.map((t) => t.vatId),
`revalidation-${runId}`
)
console.log(`submitted ${toCheck.length} numbers, ${duplicatesDropped} duplicate(s) dropped`)
const job = await waitForJob(jobId)
console.log(
`${job.summary.valid} valid, ${job.summary.invalid} invalid, ${job.summary.errors} errored`
)
for await (const position of pageResults(jobId)) {
const customerIds = byVatId.get(position.vatId) ?? []
if (position.error) {
continue // Step 3's branching — manual review or resubmit, never mark invalid
}
for (const customerId of customerIds) {
await recordCheck(customerId, position) // Step 4
}
}
If something looks wrong early – a bad export, the wrong list – POST /v1/vat/bulk/{jobId}/cancel is idempotent and stops the job at its next position. Positions already answered keep their results and stay billed; those checks ran and their records exist. The positions it never reached stay pending permanently and cost nothing, so on a cancelled job pending means “never checked”, not “not checked yet” – resubmit the ones you still need as a new batch. Revoking the API key the job was submitted with stops it the same way, as cancelled with errorCode: 'API_KEY_REVOKED'.
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 answered position against the last stored outcome and act only on the transitions:
type StoredOutcome = {
customerId: string
vatId: string
valid: boolean
source: string
consultationNumber: string | null
checkId: string
processedAt: string
}
// Only ever called for a position with no `error` — see Step 3.
type AnsweredPosition = BulkPosition & {
valid: boolean
source: string
checkId: string
processedAt: string
}
function diffOutcome(
previous: StoredOutcome,
fresh: AnsweredPosition
): '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 verdict — never mutate the old one.
async function recordCheck(customerId: string, fresh: AnsweredPosition) {
await db.insert('vat_checks', {
customerId,
vatId: fresh.vatId,
valid: fresh.valid,
source: fresh.source, // 'VIES', 'CACHE', or a national-registry code — keep it
consultationNumber: fresh.consultationNumber, // null unless a requester VAT was set on the job
checkId: fresh.checkId,
processedAt: fresh.processedAt,
})
}
Store an append-only row per check, never an overwrite – the timestamped history is the audit trail. Keep source on every row: VIES, CACHE and a national-registry code are not equivalent evidence. A position only carries a consultationNumber when a requester VAT was set on the job – that is your externally-attested evidence for each re-check, and a position with none is still a timestamped result, just of a different kind. A position also carries no company name or address; if you need those for a specific customer, follow up with GET /v1/vat/:vatId for that one number.
The went_invalid list is your action queue. Those are the customers to stop reverse-charging until the number is fixed – but handle it as 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 next question is how often to run it again, and the better answer is usually not to 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, get told when something flips.
That is what monitoring is. Instead of scheduling a manual bulk pass, you register the numbers you care about and let the re-checks run automatically. A webhook fires 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. 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 – up to 50,000 VAT IDs in a single POST /v1/vat/bulk call, which returns a job id immediately. Duplicate numbers collapse into one position on intake and are billed once; you poll the job for progress and page through the results. There’s no pacing to hand-roll – the job paces itself against VIES.
What happens if VIES goes down partway through a bulk run?
The job steps aside from whatever member state VIES is struggling with and keeps working the rest of the batch. Positions for that country come back with an error code (VIES_UNAVAILABLE or UPSTREAM_TIMEOUT) and valid: null – not false – and the job still reaches finished once every position has an answer. Resubmit just the errored VAT IDs as a new job once the node recovers, and never let a null verdict overwrite a customer’s previously stored status.
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 without building any of this yourself
vatnode’s bulk endpoint is POST /v1/vat/bulk with a Bearer key: up to 50,000 numbers per job, paced against VIES and backed off per member state the way this post describes. Per-position errors are structured, so a null verdict never gets mistaken for an invalid one. Leave the requester VAT unset and a position can still recover through a national registry when VIES is down; set one in dashboard Account details and every position is checked live against VIES for its consultation number, with no fallback behind it.
Get a free API key to try the flow end to end · Full API reference