Bulk VAT Validation
POST /v1/vat/bulk takes up to 50,000 EU VAT numbers, records them as a job and answers immediately with 202 Accepted. Every number then runs through the same VIES + national-fallback pipeline as GET /v1/vat/:vatId, in the background. You poll the job and page through the results.
Why It Is Asynchronous
A VAT number that is not already cached costs a real round trip to VIES, and VIES is a public service with its own load. A hundred cold numbers therefore cannot be answered honestly inside one HTTP request – and 50,000 certainly cannot. Submitting a batch is a write; the validation happens afterwards.
Jobs are also deliberately throttled: five checks at a time whatever the batch size, and a member state VIES is currently struggling with is skipped and retried later rather than hammered. Only that member state waits – the rest of the batch carries on. There is no priority queue behind this: your GET /v1/vat/:vatId calls are not routed ahead of your batches, they are simply not competing with an unbounded one.
When to Use It
- Cleaning an imported customer or supplier list before it hits your billing system
- Periodic re-validation of VAT numbers you already store, in scheduled batches
- Pulling a fresh, timestamped result set together ahead of a VAT audit
For a single VAT ID looked up on demand – at checkout, on account creation – use GET /v1/vat/:vatId instead; it answers in one request and returns company name, address and other enrichment fields that a job's positions omit.
Endpoints
| Endpoint | What it does |
|---|---|
| POST /v1/vat/bulk | Records a batch, returns 202 with a job id |
| GET /v1/vat/bulk/{jobId} | Status, progress and running summary |
| GET /v1/vat/bulk/{jobId}/results | Positions in submit order, paginated |
| POST /v1/vat/bulk/{jobId}/cancel | Asks a running job to stop; idempotent |
| GET /v1/vat/bulk | Your jobs, newest first, paginated |
All five require an API key, the same as every other billable endpoint – see Authentication.
Submitting a Batch
| Field | Type | Required | Description |
|---|---|---|---|
| vatIds | string[] | Yes (body) | 1–50,000 VAT numbers, each with its country prefix (e.g. IE6388047V). Normalized the same way as the single endpoint (uppercased, whitespace/dashes stripped) and then de-duplicated. A body with 0 or more than 50,000 entries is rejected with a 400 before anything is recorded. |
No query parameters. The requester VAT used for consultation numbers comes entirely from your account-level dashboard Account details and is frozen onto the job when it is accepted – changing the setting later does not change a job already in flight, so a batch never comes back half audit-grade.
Send an Idempotency-Key header. It is optional, and this is the call where it earns its keep: if the connection drops before the 202 arrives you cannot tell whether up to 50,000 billable positions were accepted, and a plain retry buys them twice. Mint one key per batch, resend it verbatim – with the exact same vatIds — on every retry, and the same job comes back: no second job, no second charge. Reusing the key with a different list is refused with 409 IDEMPOTENCY_KEY_CONFLICT instead of silently answering from the earlier batch or quietly starting a second job. Keys are scoped to your account, and 8–200 characters of letters, digits, ., - or _. A key that does not parse is refused with IDEMPOTENCY_KEY_INVALID rather than ignored – being told is better than believing you were protected.
curl https://api.vatnode.dev/v1/vat/bulk \
-H "Authorization: Bearer vat_live_your_key" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: batch-2026-09-07-run-1" \
-d '{
"vatIds": ["IE6388047V", "DE143454214", "DE12345"]
}'{
"jobId": "0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60",
"status": "queued",
"totalItems": 3,
"duplicatesDropped": 0,
"createdAt": "2026-09-07T09:12:00.000Z"
}Polling the Job
Progress counters live on the job itself, so this stays one cheap read however large the batch is. Poll every few seconds until status is terminal — finished, cancelled or failed, the last for a job that stopped early on its own with errorCode saying why. Every job reaches one of the three: there is a bound on how many times a job may step aside from a degraded member state, and on the last attempt those positions are answered rather than postponed again. Branch on the whole terminal set, not on finished alone, or a job that ends any other way leaves your poller running until it expires.
{
"jobId": "0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60",
"status": "processing",
"totalItems": 3,
"processedItems": 2,
"summary": { "valid": 1, "invalid": 0, "errors": 1 },
"createdAt": "2026-09-07T09:12:00.000Z",
"startedAt": "2026-09-07T09:12:01.100Z",
"finishedAt": null,
"cancelRequestedAt": null,
"expiresAt": "2026-10-07T09:12:00.000Z",
"errorCode": null
}finished means every position has an answer — including positions whose answer is an error. It is not a claim that every VAT number validated successfully. Read summary.
Reading the Results
Positions come back in submit order and can be read while the job is still running – a position that has not been reached yet has status: "pending". Pagination is ?page= and ?limit= (up to 100 per page).
On a cancelled job, pending is final rather than temporary: the job stopped before reaching those positions, so they were never checked, were never charged, and will not be answered. Resubmit the ones you still need as a new batch.
{
"items": [
{
"position": 0,
"vatId": "IE6388047V",
"status": "done",
"valid": true,
"source": "VIES",
"consultationNumber": "WAPIAAAAWmn3dGDs",
"checkId": "018f2c1e-9a1b-7c3d-8e4f-5a6b7c8d9e0f",
"error": null,
"processedAt": "2026-09-07T09:12:03.400Z"
},
{
"position": 2,
"vatId": "DE12345",
"status": "done",
"valid": null,
"source": null,
"consultationNumber": null,
"checkId": null,
"error": {
"code": "INVALID_FORMAT",
"message": "Invalid VAT ID format for DE"
},
"processedAt": "2026-09-07T09:12:03.410Z"
}
],
"total": 3,
"page": 1,
"pages": 1
}Position Fields
| Field | Type | Description |
|---|---|---|
| position | number | 0-based index in submit order, after de-duplication. |
| vatId | string | The normalized VAT number. |
| status | string | pending or done. |
| valid | boolean | null | true/false once a determination was reached; null while pending or when error is set. |
| source | string | null | VIES, CACHE, or a national fallback code – same meaning as on the single endpoint. |
| consultationNumber | string | null | Present when a requester VAT was set on your account as the job was accepted and that position was validated directly against VIES. Never cached. |
| checkId | string | null | Receipt for the stored check behind this position – the same identifier the single endpoint returns, and the one that outlives the job. Null for a position that produced no verdict. |
| error | object | null | Present only when this position failed. Carries code and message — see Error Codes. |
A position carries a leaner shape than the single endpoint – no companyName, companyAddress or other enrichment fields. If you need company details for a specific number, follow up with GET /v1/vat/:vatId for that one.
Cancelling a Job
POST /v1/vat/bulk/{jobId}/cancel is idempotent. The job stops at its next position and ends cancelled. Positions already answered keep their results and stay billed — those checks ran and their records exist. Positions the job never reached stay pending and are not charged. Cancelling a job that has already finished changes nothing and returns it as it stands.
curl -X POST https://api.vatnode.dev/v1/vat/bulk/0f8b1d64-4a1e-4a5e-9f4a-2b1c8d3e5f60/cancel \
-H "Authorization: Bearer vat_live_your_key"Revoking the API key a job was submitted with stops it the same way. Revocation means nothing more is read or written with that key, and a batch running in the background is no exception – so the job ends cancelled with errorCode: "API_KEY_REVOKED" and the remainder is neither checked nor billed. Nothing it had already collected is hidden: those positions keep their results and their checkId, readable through any other key on the account and in your check history.
Billing & Quota
Same price per check, no batch fee
Every position that gets a real valid result — true or false – counts as one validation against your monthly quota, exactly like a call to GET /v1/vat/:vatId. Quota is spent position by position as each is answered, never reserved for the batch up front. If a job pushes you past your plan's monthly quota, the extra positions are billed at your plan's overage rate on Starter (€0.025/request) or Pro (€0.015/request) – the free plan is hard-capped, so those positions come back RATE_LIMITED instead and the job still finishes.
Any position that comes back with an error costs nothing – it either never touched your quota or the reservation was refunded. In practice that covers:
- Malformed VAT IDs (
INVALID_FORMAT) — rejected before any quota is reserved. - Upstream failures — VIES or a national fallback being unreachable (
VIES_UNAVAILABLE,UPSTREAM_TIMEOUT), an invalid requester (INVALID_REQUESTER), or any other unexpected error – the reservation for that position is refunded. - Quota-exceeded positions (
RATE_LIMITED) — by definition never reserved a slot in the first place. - Unrecorded checks (
AUDIT_WRITE_FAILED) — the check ran but its record could not be stored, so no verdict and nocheckIdis issued and the reservation is refunded. Resubmit those numbers.
One gap in the "one validation per position" guarantee above: if the process running the job dies at the exact point between reserving a position that has not yet been answered and recording its answer, that reservation is not returned. The position stays pending, is answered again once the job resumes, and its quota unit is spent twice – bounded by the five positions a pass works on at once, so a crash costs at most a handful of positions, not the batch.
Duplicate VAT IDs within the same submission are de-duplicated as the job is accepted, so listing the same number twice produces one position and is billed once – not twice. The 202 response reports how many entries that removed.
The RateLimit-Limit/RateLimit-Remaining headers described in Rate Limit & Quota Headers are emitted on the single-check endpoint only – a bulk submission has not validated anything yet, so it has nothing to report. Watch the job's summary and any per-position RATE_LIMITED entries instead.
Behavior Notes
- De-duplication. Repeated VAT IDs in
vatIdsare collapsed to one position, first-seen order preserved. - The requester is frozen at submit time. When your account has a requester VAT configured, every position is checked live against VIES so it can carry a fresh consultation number – a cached result never carries one. This also means national-fallback recovery is skipped in this mode, same as the single endpoint: a VIES outage surfaces as
VIES_UNAVAILABLEfor that position instead of silently falling back. - A crash resumes, it does not restart. Job state lives in the database, not in a queue. If the service restarts mid-job, the job is picked up where it stopped and positions already answered are never re-checked, so their quota is never spent twice. The handful of positions that were mid-check when the process died are the exception noted above.
- Test keys behave identically. A
vat_test_key creates a real job with the same polling contract, but every VAT ID must start withXXand is answered from fixtures — no quota, no VIES call, no stored check, socheckIdis null. - Jobs expire after 30 days. The job and its positions are deleted at
expiresAt. The underlying checks remain in your check history under their owncheckId.
Error Codes
Per-position error codes use the same vocabulary as the single endpoint – full definitions in Error Handling.
| Code | Meaning |
|---|---|
| INVALID_FORMAT | This VAT ID doesn't match the expected format for its country |
| INVALID_REQUESTER | The requester VAT frozen onto this job is rejected by VIES (only when a requester is set) |
| RATE_LIMITED | Your monthly quota ran out before this position |
| VIES_UNAVAILABLE | VIES (and any national fallback) was unreachable for this position |
| UPSTREAM_TIMEOUT | The upstream check for this position timed out |
| VIES_ERROR | Unexpected VIES protocol error for this position – including a valid verdict VIES issued no consultation number for, when a requester is set |
| AUDIT_WRITE_FAILED | The check ran but its record could not be stored, so no verdict was issued – not billed, resubmit the number |
| INTERNAL_ERROR | Unhandled internal error while checking this position |
Code Examples
JavaScript
const API = 'https://api.vatnode.dev'
const headers = {
Authorization: `Bearer ${process.env.VATNODE_API_KEY}`,
'Content-Type': 'application/json'
}
async function submitBulk(vatIds) {
const res = await fetch(`${API}/v1/vat/bulk`, {
method: 'POST',
headers,
body: JSON.stringify({ vatIds })
})
if (!res.ok) throw new Error((await res.json()).error.message)
return res.json() // { jobId, status, totalItems, duplicatesDropped, createdAt }
}
async function waitForJob(jobId, { intervalMs = 3000 } = {}) {
for (;;) {
const res = await fetch(`${API}/v1/vat/bulk/${jobId}`, { headers })
const job = await res.json()
if (['finished', 'cancelled', 'failed'].includes(job.status)) {
return job
}
await new Promise((r) => setTimeout(r, intervalMs))
}
}
async function* readResults(jobId, limit = 100) {
for (let page = 1; ; page++) {
const res = await fetch(`${API}/v1/vat/bulk/${jobId}/results?page=${page}&limit=${limit}`, {
headers
})
const { items, pages } = await res.json()
yield* items
if (page >= pages) return
}
}
// Usage
const { jobId, duplicatesDropped } = await submitBulk(['IE6388047V', 'DE143454214', 'DE12345'])
console.log(`submitted, ${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 item of readResults(jobId)) {
if (item.error) console.warn(item.vatId, item.error.code)
else console.log(item.vatId, item.valid ? 'valid' : 'invalid', item.source, item.checkId)
}Python
import os
import time
import requests
API = 'https://api.vatnode.dev'
HEADERS = {'Authorization': f'Bearer {os.environ["VATNODE_API_KEY"]}'}
def submit_bulk(vat_ids):
r = requests.post(f'{API}/v1/vat/bulk', headers=HEADERS, json={'vatIds': vat_ids})
r.raise_for_status()
return r.json()
def wait_for_job(job_id, interval=3):
while True:
r = requests.get(f'{API}/v1/vat/bulk/{job_id}', headers=HEADERS)
r.raise_for_status()
job = r.json()
if job['status'] in ('finished', 'cancelled', 'failed'):
return job
time.sleep(interval)
def read_results(job_id, limit=100):
page = 1
while True:
r = requests.get(
f'{API}/v1/vat/bulk/{job_id}/results',
headers=HEADERS,
params={'page': page, 'limit': limit},
)
r.raise_for_status()
data = r.json()
yield from data['items']
if page >= data['pages']:
return
page += 1
# Usage
job = submit_bulk(['IE6388047V', 'DE143454214', 'DE12345'])
print(f"submitted, {job['duplicatesDropped']} duplicate(s) dropped")
done = wait_for_job(job['jobId'])
print(f"{done['summary']['valid']} valid, {done['summary']['errors']} errored")
for item in read_results(job['jobId']):
if item['error']:
print(item['vatId'], item['error']['code'])
else:
print(item['vatId'], 'valid' if item['valid'] else 'invalid', item['source'], item['checkId'])How to Bulk-Validate a List of VAT Numbers
- Submit the VAT IDs. POST the array as { "vatIds": [...] } to /v1/vat/bulk with your live API key — up to 50,000 numbers, each with its two-letter country prefix. Set your own EU VAT once in dashboard Account details first if you want a consultation number on every position.
- Keep the job id. The call returns 202 with a jobId, the number of positions recorded, and how many duplicate entries were dropped. Nothing has been validated yet.
- Poll the job. GET /v1/vat/bulk/{jobId} every few seconds. It reports status, processedItems and a running valid/invalid/errors summary. Stop when status is terminal — finished, cancelled or failed.
- Page through the results. GET /v1/vat/bulk/{jobId}/results?page=1&limit=100 returns positions in submit order, each with its verdict, source, consultation number and checkId. You can read it while the job is still running — unanswered positions come back as pending.
FAQ
How many VAT numbers can I validate in one request?
Up to 50,000 per call to POST /v1/vat/bulk. The request records the batch and returns a job id straight away; the numbers are validated in the background. A body with 0 or more than 50,000 vatIds is rejected with a 400 before anything is recorded.
Why does bulk validation return a job instead of results?
Because a VAT number that is not already cached costs a real VIES round trip. Even a hundred of them cannot be answered honestly inside one HTTP request, and 50,000 certainly cannot. Submitting records the batch in a few hundred milliseconds; you then poll GET /v1/vat/bulk/{jobId} and page through the results as they land.
How long does a bulk job take?
It depends on how many of your numbers are already cached and how healthy VIES is for the member states involved. Jobs run five checks at a time whatever the batch size, and step aside from member states VIES is struggling with, so a batch takes a bounded share of upstream capacity rather than as much as it can get. Stepping aside holds back only that member state’s positions — the rest of the batch keeps moving, and the held-back ones are retried later. Poll the job for processedItems rather than assuming a rate.
Does bulk validation cost more than checking one VAT number at a time?
No. Each unique VAT ID in the batch counts as exactly one validation against your monthly quota, the same as GET /v1/vat/:vatId. There is no per-batch surcharge, and quota is spent per position as it is answered — not reserved up front.
What happens if I hit my monthly quota partway through a job?
That depends on your plan. Starter and Pro have overage, so the job simply keeps going and the extra positions are billed at your plan’s overage rate — nothing is blocked mid-batch. The free plan is hard-capped: once it is used up, every remaining position comes back with a RATE_LIMITED error instead. Either way the job reaches finished — a RATE_LIMITED position is an answered position — and positions already answered keep their results, so you can resubmit just the ones you need after upgrading or after the quota resets.
What stops me being charged twice if my request times out before I get the job id?
Send an Idempotency-Key header with the POST — 8 to 200 characters of letters, digits, dot, dash or underscore, minted once per batch and resent verbatim, with the exact same vatIds, on every retry. The same key and batch always return the same job, so a retry after a dropped connection cannot buy it a second time. Reusing the key with a different list is rejected with 409 IDEMPOTENCY_KEY_CONFLICT rather than silently mixed up with the earlier batch. It is optional, but on a call that can carry 50,000 billable positions it is worth sending. A key that does not parse is refused with IDEMPOTENCY_KEY_INVALID rather than ignored.
What happens to a running job if I revoke the API key that submitted it?
The job stops at its next position and ends cancelled with errorCode API_KEY_REVOKED. Revoking a key means nothing more is read or written with it, and a batch running in the background is no exception, so the remainder is never checked and never billed. Nothing it had already collected is hidden: those positions keep their results and their checkId, readable through your other keys and in your check history.
Are duplicate VAT numbers in the same request billed twice?
No. vatIds is de-duplicated when the job is accepted (first-seen order preserved), so a repeated VAT ID becomes one position and is billed once. The 202 response reports how many entries that removed as duplicatesDropped.
Can I cancel a running bulk job?
Yes — POST /v1/vat/bulk/{jobId}/cancel. The job stops at its next position and ends cancelled. Positions already answered keep their results and stay billed: those checks ran and their records exist. Positions the job never reached stay pending for good — they were not checked and not charged. Resubmit the ones you still need as a new batch.
How long are bulk job results available?
Thirty days from submission, given by expiresAt on the job. After that the job and its positions are deleted. The checks themselves stay in your check history under their own checkId, so nothing auditable is lost.
See Also
- VAT Validation — the single-check endpoint, with full company enrichment and consultation-number details
- VIES API — what the underlying EU VIES register is and how vatnode wraps it
- Documentation — full API reference
- Pricing — monthly quotas and overage rates by plan