Originally published at vatnode.dev. The version on vatnode.dev is the canonical source — refer to it for the latest content.
Handling VIES Errors in Code
Most VAT integrations treat validation as a boolean: the number is valid, or it isn't. That works right up until VIES returns a 503, and the code that expected valid: true | false gets neither. What happens next is where real integrations diverge from demos.
VIES is a network of roughly 27 national nodes — one per member state, plus XI for Northern Ireland — federated behind a single European Commission endpoint. Every one of those nodes can be slow, rate-limited, or offline independently, with no SLA and no status page. So on any given request you are not choosing between "valid" and "invalid". You are choosing between a completed check and an operational condition that stopped the check from completing at all. If your error handling collapses those two into one, you will eventually reject a real customer because Germany's node was down for ten minutes.
This guide walks every error code the vatnode GET /v1/vat/:vatId endpoint can return, what each one actually means, and how to handle it in production — retry, fail, or degrade. The codes below are the public contract, but verify them against the error reference before you ship, since new fallbacks can change which code you see for a given failure.
Why VIES errors need a real strategy
The core mistake is semantic, not technical. A failed round-trip to VIES is not evidence that a VAT number is fake. If the Italian node times out, you have learned nothing about the Italian VAT number you sent — only that Italy's system did not answer in time. Persisting that as valid: false is a data-integrity bug: you've written a verdict you were never given.
There are exactly three outcome classes:
- The check completed and the number is valid. You have an answer.
- The check completed and the number is invalid. You also have an answer.
- The check did not complete. Format rejected, quota exhausted, node down, request timed out, requester misconfigured. You have no answer — only an operational condition to react to.
Everything in this article is about handling class 3 correctly, because class 1 and 2 are the easy part. The rule that holds the whole thing together: an incomplete check is a reason to retry, degrade, or fix your input — never a reason to record a negative result.
Three error classes: your input, your request, the upstream
Before the per-code detail, group the seven error codes by who owns the fix:
-
Your input —
INVALID_FORMAT(400) andINVALID_REQUESTER(422). The request was structurally wrong. Retrying identical bytes will fail identically. Something has to change on your side first. -
Your request budget —
RATE_LIMITED(429). Nothing is broken; you've spent your monthly quota. This resolves on its own (or when you upgrade), so it's retryable, but with a longer horizon than a node blip. -
The upstream —
VIES_UNAVAILABLE(503),VIES_ERROR(502),UPSTREAM_TIMEOUT(504). VIES or a member-state node is down, slow, or throwing protocol faults. The number you sent is probably fine; you just couldn't reach a verdict. These are the "requeue, don't block" cases. -
Us —
INTERNAL_ERROR(500). An unhandled fault on the vatnode side. Rare, retryable, and worth alerting on if it persists.
Every error body carries a machine-readable code, so you branch on code, not on the HTTP status or the human message:
{
"error": {
"code": "VIES_UNAVAILABLE",
"message": "The VIES node for IT is temporarily unavailable. Retry later."
}
}
The error codes
INVALID_FORMAT (400)
The VAT ID failed structural validation. Either vatnode caught it locally against the per-country format rules, or VIES itself rejected the shape before any lookup happened. Crucially, there was no round-trip — nobody checked whether the number is registered, because it can't be, in this form.
Do not retry. Do not record it as valid: false either — a malformed string is not a validated-and-rejected VAT number. This is user-input feedback: surface it inline at the point of entry so the person fixes the typo. The validation flow guide covers where format checks belong relative to the network call.
INVALID_REQUESTER (422)
This one confuses people because it looks like a validation failure but is about the checker's identity, not the number being checked. To return a VIES consultation number, vatnode runs a requester-qualified checkVatApprox lookup using a requester VAT ID — configured on your account or passed as a query param. A 422 means that requester VAT number is invalid in VIES.
The number you were trying to validate is untouched; you never got a verdict on it. Do not blind-retry — the requester won't heal itself. Fix the requester setting (correct it, or drop it if you don't need consultation numbers on that path), then re-run.
RATE_LIMITED (429)
You've exceeded your monthly quota. This is a fact about your plan, not about any VAT number. It is retryable, but not on a seconds-scale backoff — retrying in 200ms just burns CPU against a wall that resets monthly. Either defer the work, degrade gracefully (apply your standard-VAT default and queue for later), or raise the ceiling. Alert someone if you're hitting it in normal operation, because it means capacity planning, not code, is the fix.
VIES_UNAVAILABLE (503)
VIES, or the specific member-state node, is down — and every available fallback also failed. This is the most common transient failure and the one most likely to be mishandled. "Could not check" is not "invalid." The customer is almost certainly legitimate; you simply couldn't reach a system that could confirm it.
Requeue the check with backoff and let the transaction proceed on your safe default. Never persist a 503 as a negative result. The dedicated write-up on surviving VIES downtime goes deeper on queue design and node-level behavior.
VIES_ERROR (502)
An unexpected VIES protocol fault — typically an IP_BLOCKED or VAT_BLOCKED condition surfaced by the upstream, or a malformed SOAP response. It's an infrastructure fault, not a verdict. Treat it like a 503 for retry purposes: back off and requeue. If 502s cluster on one country or one key, that's worth an alert — it can mean a blocked identity rather than a passing blip.
UPSTREAM_TIMEOUT (504)
VIES accepted the request but didn't answer in time, and no fallback was available to cover the gap. Same semantics as 503: the check did not complete, so you have no result. Requeue it. A timeout tells you nothing about the number — resist any urge to treat "slow" as "suspicious."
INTERNAL_ERROR (500)
An unhandled error on the vatnode side. It's retryable — back off and try again — but unlike the upstream codes, a sustained stream of 500s is on us, not on VIES. Log it with the requestId from the response and, if it persists, it's a support conversation.
Decision table: retry vs fail vs degrade per code
| Code | HTTP | Class | Retry? | User-facing behavior |
|---|---|---|---|---|
INVALID_FORMAT |
400 | Your input | No | Show inline "check the VAT number" error |
INVALID_REQUESTER |
422 | Your input | No | Fix requester config; don't expose to end user |
RATE_LIMITED |
429 | Your budget | Yes, long horizon | Degrade to default, queue, alert ops |
VIES_UNAVAILABLE |
503 | Upstream | Yes, backoff | Proceed on default, requeue, never mark invalid |
VIES_ERROR |
502 | Upstream | Yes, backoff | Proceed on default, requeue, alert on clusters |
UPSTREAM_TIMEOUT |
504 | Upstream | Yes, backoff | Proceed on default, requeue, never mark invalid |
INTERNAL_ERROR |
500 | Us | Yes, backoff | Requeue, alert if sustained |
"Degrade" here means: don't block the user's flow. Apply your standard-VAT default, let them through, and reconcile asynchronously. The reasoning behind that default is in non-blocking validation.
The non-blocking pattern
Here's a typed handler that branches on code and returns an explicit disposition — ok, input_error, retry, or degrade — instead of pretending every failure is a false verdict. The disposition, not the raw error, drives what your checkout or signup does next.
type VatCode =
| 'INVALID_FORMAT'
| 'INVALID_REQUESTER'
| 'RATE_LIMITED'
| 'VIES_UNAVAILABLE'
| 'VIES_ERROR'
| 'UPSTREAM_TIMEOUT'
| 'INTERNAL_ERROR'
type VatSuccess = {
valid: boolean
vatId: string
countryCode: string
source: string
consultationNumber: string | null
}
type Disposition =
| { kind: 'ok'; result: VatSuccess }
| { kind: 'input_error'; code: VatCode; message: string }
| { kind: 'retry'; code: VatCode }
| { kind: 'degrade'; code: VatCode }
async function checkVat(vatId: string): Promise<Disposition> {
const res = await fetch(`https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`, {
headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
})
if (res.ok) {
return { kind: 'ok', result: (await res.json()) as VatSuccess }
}
const { error } = (await res.json()) as {
error: { code: VatCode; message: string }
}
switch (error.code) {
// Your input — a retry sends the same bad bytes. Fix it first.
case 'INVALID_FORMAT':
case 'INVALID_REQUESTER':
return { kind: 'input_error', code: error.code, message: error.message }
// Upstream or budget — the number is probably fine, we just have no verdict.
// Let the flow continue on a safe default and reconcile later.
case 'RATE_LIMITED':
case 'VIES_UNAVAILABLE':
case 'VIES_ERROR':
case 'UPSTREAM_TIMEOUT':
case 'INTERNAL_ERROR':
return { kind: 'degrade', code: error.code }
default:
// Unknown code — fail closed to a retry, never to "invalid".
return { kind: 'retry', code: error.code }
}
}
Note the default branch. If vatnode ever adds a code you haven't handled, it degrades to a retry — it does not fall through to treating the number as invalid. Failing closed toward "we don't know yet" is the only safe direction here.
The caller then requeues anything marked degrade with backoff, and stops permanently on input_error:
const RETRY_DELAYS_MS = [5, 15, 30, 60, 120].map((m) => m * 60_000)
async function handleVat(vatId: string, customerId: string, attempt = 0) {
const d = await checkVat(vatId)
switch (d.kind) {
case 'ok':
// Real verdict — persist valid AND source; consultationNumber is null
// for national-fallback sources, which is expected, not an error.
return persistResult(customerId, d.result)
case 'input_error':
// No round-trip happened. Surface to the user (400) or ops (422);
// never write this as valid: false.
return flagInput(customerId, d.code, d.message)
case 'retry':
case 'degrade':
if (attempt >= RETRY_DELAYS_MS.length) {
return flagForManualReview(customerId, vatId, d.code)
}
// Could-not-check → requeue. The transaction already proceeded on the
// standard-VAT default; this only backfills the verdict.
return queue.add(
'revalidate-vat',
{ vatId, customerId, attempt: attempt + 1 },
{ delay: RETRY_DELAYS_MS[attempt] }
)
}
}
Two things to keep straight. First, persistResult should store source immutably alongside valid — a VIES answer and a national-fallback answer are not interchangeable as audit evidence, and consultationNumber being null on a fallback is normal, not a failure. Second, nothing in the retry/degrade path ever writes a verdict. It only schedules another attempt. The verdict is written exactly once, when a check actually completes.
FAQ
Which VIES errors should I retry?
Retry the transient upstream ones with backoff — RATE_LIMITED (429), VIES_UNAVAILABLE (503), UPSTREAM_TIMEOUT (504), and VIES_ERROR (502). Do not retry INVALID_FORMAT (400) or INVALID_REQUESTER (422); those are your input and won't change on a retry.
Does a VIES timeout mean the number is invalid?
No. A timeout or an unavailable node means the check could not complete — "could not check" is not "invalid". Requeue it and try again; never store it as a negative result.
What does INVALID_REQUESTER mean?
It means the requester VAT number you supplied for a consultation-number lookup is itself invalid — the checker's identity failed, not the number you were checking. Fix the requester setting rather than retrying.
Get structured error codes instead of raw SOAP faults
vatnode wraps VIES, its national fallbacks, and every failure mode behind one production-grade VIES API with the machine-readable codes above — so your handler branches on
code, not on parsing XML. Free plan, 100 requests/month.
Top comments (0)