Submit and Poll an Async VAT Bulk Validation Job in Node.js

11 Nov 2026

Submit and Poll an Async VAT Bulk Validation Job in Node.js

A bulk VAT ID check should be a job you submit, not a loop you babysit. POST /v1/vat/bulk takes up to 50,000 IDs and returns a job id straight away; you poll for the outcome. This post builds a bulk VAT number checker in Node.js that survives retries, outages and a restart of your own process. Scope is the EU-27 plus Northern Ireland (XI), and the bulk validation reference has the full request and response shapes. For the revalidation use case, see how to bulk re-validate customer VAT IDs; this post is the client underneath it.

The flow in four calls

All calls need Authorization: Bearer <key>.

CallPurposeReturns
POST /v1/vat/bulkSubmit the list202 with jobId, status, totalItems, duplicatesDropped
GET /v1/vat/bulk/{jobId}Read status and countersstatus, processedItems, summary, expiresAt, errorCode
GET /v1/vat/bulk/{jobId}/resultsPage through the positionsitems, total, page, pages
POST /v1/vat/bulk/{jobId}/cancelAsk a job to stop at its next positionthe job, usually still queued or processing

No call spends quota by itself. Quota is spent per position, by the job, as each position is answered. Polling, reading results, cancelling and listing jobs (GET /v1/vat/bulk) are free. Nothing is validated during the submit request either; it only records the batch.

Dry run first: a test key and the XX fixtures

Every account gets a vat_test_ key on first sign-in (see test mode and the test keys post). With it the job is real, with real ids and the same polling contract, but nothing is billed and VIES is not called. Every position must start with XX, otherwise it answers INVALID_FORMAT. If you have no key yet, create a free account; the dry run below costs nothing.

VAT IDTest-mode answer
XX0000001valid
XX0000002valid
XX0000003invalid
XX0000004error VIES_UNAVAILABLE
XX0000005error VIES_ERROR
any other XX…, e.g. XX1234567invalid (valid: false)

This is how a failed fixture position looks in the results. valid is null, never false:

{
  "position": 3,
  "vatId": "XX0000004",
  "status": "done",
  "valid": null,
  "source": null,
  "consultationNumber": null,
  "checkId": null,
  "error": { "code": "VIES_UNAVAILABLE", "message": "VIES service is temporarily unavailable" },
  "processedAt": "2026-11-11T09:00:03.000Z"
}

Submit with an idempotency key you can safely retry

A single GET /v1/vat/{vatId} is safe to repeat (the idempotency post covers that). A bulk submit is not: if the connection drops before the 202 arrives, you cannot tell whether the batch was accepted, and a blind retry could buy it twice.

The Idempotency-Key header fixes that. The rules, as implemented:

  • The key is optional, matches ^[A-Za-z0-9._-]{8,200}$, and is scoped to your account.
  • Same key, same list: 202 with the same job, in whatever state it has reached. No second job, no second charge.
  • Mint the key once per batch, persist it before the first attempt, and resend it verbatim on every retry. A key generated inside the retry loop defeats the point.

Every error branch on submit

  • 400 INVALID_REQUEST rejects the whole POST and records nothing. It covers an empty array, more than 50,000 entries, a non-array, a non-string entry, and any single entry whose raw length is under 4 or over 20 characters. The length is checked before normalisation, so pre-filter your list for raw length 4 to 20 yourself; one stray cell would otherwise sink the batch.
  • INVALID_FORMAT on a position is different. An entry that passes the length check but fails its country’s format is accepted, and that position answers INVALID_FORMAT. It is not billed, and the job carries on.
  • 400 IDEMPOTENCY_KEY_INVALID: the header does not match the pattern. It is rejected, not ignored.
  • 409 IDEMPOTENCY_KEY_CONFLICT: the key was used with a different list. The fingerprint covers the normalised, de-duplicated list in submit order, so the same IDs reordered also conflict. The original job is untouched. Use a new key only when the list really is a new batch.
  • A missing or invalid API key is rejected before any of this; see authentication.

Entries are normalised on intake (trimmed, uppercased, spaces, - and . removed) and then de-duplicated, keeping first-seen order. duplicatesDropped tells you how many went, so totalItems can be smaller than what you sent. One position is one quota unit. Retry the same request with the same key on network errors, timeouts and 5xx. Do not retry 4xx; they will answer the same way. Error bodies are always { "error": { "code", "message", "requestId" } }, and the full list is in the error reference and handling VIES errors in code.

Poll until a terminal status

status is one of queued, processing, finished, cancelled or failed. The last three are terminal, and a client has to branch on all of them:

  • finished: every position has an answer, error answers included. It does not mean everything is valid. Read summary (valid, invalid, errors).
  • cancelled: you cancelled it, or the API key was revoked (errorCode is API_KEY_REVOKED, and cancelRequestedAt may be null).
  • failed: errorCode says why. Branch on it even if you never see it.

On cancelled and failed, positions that were answered keep their answers and positions that were not reached stay pending forever, unbilled.

The docs say to poll every few seconds and publish no ETA; duration depends on cache hits and VIES health. The interval in the client (2 s growing to 15 s) is our suggestion, not a documented rate. The client also tolerates a few failed polls in a row, since a transient 5xx on a read says nothing about the job, and enforces a deadline you choose.

Read results while the job runs

GET /v1/vat/bulk/{jobId}/results?page=1&limit=100 returns items, total, page and pages. limit is 1 to 100 and defaults to 20, so ask for 100. Positions come in submit order, numbered from 0 after de-duplication, and a pending position flips to done in place.

To use answers early, re-read from the first page that still held a pending position and key what you keep by position. Positions are not necessarily answered in order, so later pages can already hold done ones. readResults below returns that first page and stores done items in a Map. The script calls it once, after the job is terminal. To consume early, call it between polls and pass the page it returned as startPage next time. Test valid === true and valid === false; valid is null for pending and failed positions.

Resubmit retryable positions as a new job

A failed position has valid: null, checkId: null and an error. HTTP stays 2xx whatever the position outcomes are. Retrying means collecting those VAT IDs and submitting them as a new job with a new key:

  • VIES_UNAVAILABLE, UPSTREAM_TIMEOUT, AUDIT_WRITE_FAILED: retry later.
  • RATE_LIMITED: retry after upgrading or after the quota resets.
  • INVALID_REQUESTER: usually means VIES rejected your requester VAT. Fix it in Account details first. The requester is read there only, at submit time, and stays with the job.
  • INVALID_FORMAT: fix the source data.
  • VIES_ERROR is a catch-all that includes blocked VAT IDs or IPs, which a retry repeats, so this client does not retry it automatically. Review those positions and resubmit them once, on purpose. INTERNAL_ERROR goes to the same pile.

The consultation number is the VIES requestIdentifier. A position carries one only on a valid verdict checked live against VIES, and only if a requester VAT was set when the job was submitted. If VIES returns a valid verdict without one, the position is withheld as VIES_ERROR and not billed.

Bulk positions carry no company name or address. A sole trader’s VAT number identifies a person, and in some countries it is built on their personal ID number, so treat stored results as personal data.

404 and the 30-day window

expiresAt is 30 days after submission. After that the job and its positions are removed by the next daily cleanup and reads return 404 NOT_FOUND, as they do for an unknown job or one from another account. A malformed jobId is a 400 INVALID_REQUEST. The checks themselves stay in your check history under their checkId, on that history’s own retention. Store the jobId with your batch record and copy results out well inside the window.

Exact quota accounting

Quota is spent per position as it is answered, the same as for GET /v1/vat/{vatId}; nothing is reserved up front.

  • Billed: a position that ends with a true or false verdict. Without a requester set, it can come from VIES, a national source or the cache. With one set, the cache is skipped and a valid verdict needs a consultation number.
  • Not billed: duplicates, INVALID_FORMAT, upstream errors (VIES_UNAVAILABLE, UPSTREAM_TIMEOUT, VIES_ERROR, INVALID_REQUESTER; any charge is refunded), INTERNAL_ERROR, AUDIT_WRITE_FAILED, a missing consultation number when a requester is set, RATE_LIMITED, positions never reached after a cancel or revocation, and all of test mode.

If you run out mid-job on a plan with overage, the remaining positions are billed at the overage rate. On a hard-capped plan every remaining position answers RATE_LIMITED, and the job still finishes. The free plan is 100 requests a month, no card; rates are on /pricing.

One edge to know about: if the API process is interrupted between charging a position and recording its answer, that position is answered again when the job resumes and charged twice. A job runs five checks at a time, so the worst case is a handful of quota units, not a repeated batch.

The full client

Node.js 18 or newer, server-side only; the key never goes in browser code. Save as bulk.mjs and run VATNODE_API_KEY=vat_test_... node bulk.mjs. The batch key is written to ./batch.key before the first request; delete the file when you start a different batch.

import { randomUUID } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'

const BASE = 'https://api.vatnode.dev'
const KEY = process.env.VATNODE_API_KEY
if (!KEY) throw new Error('Set VATNODE_API_KEY')

const TERMINAL = new Set(['finished', 'cancelled', 'failed'])
const RETRYABLE = new Set(['VIES_UNAVAILABLE', 'UPSTREAM_TIMEOUT', 'AUDIT_WRITE_FAILED'])
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const backoff = (attempt) => {
  const ceiling = Math.min(30_000, 1000 * 2 ** (attempt - 1))
  return ceiling / 2 + Math.random() * (ceiling / 2)
}

class ApiError extends Error {
  constructor(status, e) {
    super(`${status} ${e.code ?? 'UNKNOWN'}: ${e.message ?? ''} (requestId ${e.requestId ?? '-'})`)
    this.status = status
    this.code = e.code
  }
}

// Network errors, timeouts (TimeoutError) and 5xx are transient.
const isTransient = (err) => !(err instanceof ApiError) || err.status >= 500

async function call(method, path, { body, headers } = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      ...(body ? { 'Content-Type': 'application/json' } : {}),
      ...headers,
    },
    body: body ? JSON.stringify(body) : undefined,
    signal: AbortSignal.timeout(15_000),
  })
  if (res.ok) return res.json()
  let envelope = {}
  try {
    envelope = (await res.json()).error ?? {}
  } catch {}
  throw new ApiError(res.status, envelope)
}

const rawLengthOk = (s) => typeof s === 'string' && s.length >= 4 && s.length <= 20

// Same request, same key, on every attempt.
async function submitJob(vatIds, idempotencyKey, maxAttempts = 5) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await call('POST', '/v1/vat/bulk', {
        body: { vatIds },
        headers: { 'Idempotency-Key': idempotencyKey },
      })
    } catch (err) {
      if (err instanceof ApiError && err.status === 409) {
        throw new Error(
          `${err.message}. ./batch.key holds a key already used with a different list. If the list really changed, delete ./batch.key and rerun; if not, restore the original list.`
        )
      }
      if (!isTransient(err) || attempt >= maxAttempts) throw err
      await sleep(backoff(attempt))
    }
  }
}

async function pollJob(jobId, deadlineMs = 30 * 60_000) {
  const deadline = Date.now() + deadlineMs
  let interval = 2000 // our suggestion, not a documented rate
  let failures = 0
  while (Date.now() < deadline) {
    try {
      const job = await call('GET', `/v1/vat/bulk/${jobId}`)
      failures = 0
      if (TERMINAL.has(job.status)) return job
    } catch (err) {
      if (err instanceof ApiError && err.status === 404) {
        throw new Error('Job not found: expired (30 days) or not from this account')
      }
      if (!isTransient(err) || ++failures > 5) throw err
    }
    await sleep(interval)
    interval = Math.min(15_000, Math.round(interval * 1.5))
  }
  throw new Error(`Job ${jobId} not terminal before the deadline; it keeps running`)
}

// Stores answered positions by position number; returns the first page that
// still has a pending position (null when none), so you can resume from there.
async function readResults(jobId, answered, startPage = 1) {
  let firstPendingPage = null
  for (let page = startPage; ; page++) {
    const { items, pages } = await call(
      'GET',
      `/v1/vat/bulk/${jobId}/results?page=${page}&limit=100`
    )
    for (const item of items) {
      if (item.status === 'done') answered.set(item.position, item)
      else if (firstPendingPage === null) firstPendingPage = page
    }
    if (page >= pages) return firstPendingPage
  }
}

function sortResults(answered) {
  const out = { valid: [], invalid: [], retry: [], fixData: [], other: [] }
  for (const item of answered.values()) {
    if (item.valid === true) out.valid.push(item.vatId)
    else if (item.valid === false) out.invalid.push(item.vatId)
    else if (RETRYABLE.has(item.error?.code)) out.retry.push(item.vatId)
    else if (item.error?.code === 'INVALID_FORMAT') out.fixData.push(item.vatId)
    else out.other.push(`${item.vatId} ${item.error?.code}`) // never treat null as false
  }
  return out
}

// Main: dry run on a test key.
const vatIds = ['XX0000001', 'XX0000002', 'XX0000003', 'XX0000004', 'XX0000005', 'xx 0000001']
if (!vatIds.every(rawLengthOk)) throw new Error('Fix entries with raw length outside 4-20 first')

const keyFile = './batch.key'
const idempotencyKey = existsSync(keyFile) ? readFileSync(keyFile, 'utf8').trim() : randomUUID()
writeFileSync(keyFile, idempotencyKey) // persisted before the first attempt

const submitted = await submitJob(vatIds, idempotencyKey)
console.log(
  `job ${submitted.jobId}: ${submitted.totalItems} positions, ${submitted.duplicatesDropped} duplicate(s) dropped`
)

const job = await pollJob(submitted.jobId)
console.log(job.status, job.summary, job.errorCode ?? '')

const answered = new Map()
const stillPending = await readResults(job.jobId, answered)
if (stillPending !== null)
  console.log('some positions were never answered (cancelled or failed job)')
console.log(sortResults(answered))

With the fixtures above, the last line prints two valid IDs, one invalid, XX0000004 under retry and XX0000005 under other. The sixth entry, xx 0000001, normalises to a duplicate of the first, so totalItems is 5 and duplicatesDropped is 1.

Rerun the script unchanged and the same key hands back the same job: no new job, no new charge. Edit the list without deleting batch.key and you get the 409; that is the safeguard working. To resubmit retry, call submitJob(retry, randomUUID()) with a fresh key persisted the same way.

Read the bulk validation reference, get a free API key and run the whole flow on the test key before switching to a live one.

FAQ

What happens if I POST the same VAT ID list twice to the VAT bulk API?

It depends on the Idempotency-Key header. With the same key and the same list you get a 202 pointing at the same job, with no second job and no second charge. With the same key and a different list, including the same IDs in another order, you get 409 IDEMPOTENCY_KEY_CONFLICT. Without a key, two POSTs create two jobs.

How do I know a bulk VAT validation job is finished?

Poll GET /v1/vat/bulk/:jobId until status is finished, cancelled or failed. Those three are terminal. finished means every position has an answer, including error answers, so it does not mean every number is valid. Read the summary counters and the per-position results.

Which bulk VAT positions do I pay for?

A position that ends with a valid true or false verdict counts against your quota. Duplicates, INVALID_FORMAT positions, upstream errors, RATE_LIMITED positions, positions never reached after a cancel, and everything in test mode are not billed. Quota is spent per position as it is answered, not reserved for the whole batch.

How long can I read the results of a bulk VAT job?

For 30 days after submission. The job carries an expiresAt timestamp; after it the job and its positions are deleted and reads return 404 NOT_FOUND. Each check also stays in your check history under its checkId. Copy what you need into your own system before then.

Can I test a bulk VAT job without using quota?

Yes. A vat_test_ key runs a real job with the same contract, but every position must start with XX, nothing is billed and VIES is not called. The fixtures XX0000001 to XX0000005 return valid, valid, invalid, VIES_UNAVAILABLE and VIES_ERROR.