Idempotency and Retries When Calling a VAT Validation API

23 Sep 2026

Idempotency and Retries When Calling a VAT Validation API

Idempotency and Retries When Calling a VAT Validation API

A VAT validation call is one of the easiest things in your stack to retry, and one of the easiest to retry wrong. Easy, because the check is a read – an HTTP GET that changes nothing on the other end, so firing it twice can’t create a duplicate anything. Wrong, because ‘the request failed’ and ‘the VAT number is invalid’ arrive on the same code path, and if you flatten them together you’ll eventually record a real customer as invalid because a national tax node was down for ten minutes.

The real questions are which failures deserve a retry, how long to wait, and what you must never write to your database while you wait. We’ll work through them against vatnode’s GET /v1/vat/:vatId endpoint (full request and response contract in the API docs). For the full error-code taxonomy see handling VIES errors in code; here we cover only the retry and idempotency architecture on top of it.

Is a VAT check even idempotent?

It’s safe to retry, which is the property you actually care about. The stricter word ‘idempotent’ needs a caveat.

The endpoint is a GET, and a GET performs no mutation at VIES – it looks a number up in a member-state registry and reports back. Repeat it and you get the same verdict, provided the number’s registration hasn’t changed between calls. That proviso is the caveat: a VAT registration is external state. A business can deregister, or a freshly-registered number can start resolving, so two calls a day apart aren’t guaranteed byte-identical the way a keyed database write is. For retry purposes that doesn’t matter – you’re retrying seconds or minutes apart to recover from a transient fault, and across that window the verdict is stable.

There is no idempotency-key header on this API, and you don’t need one. Idempotency keys exist to make a repeated write safe – so a retried card charge bills once, a retried order-create makes one order. A VAT check writes nothing you’d want de-duplicated, so there’s nothing for a key to protect. Send the GET again and move on.

One honest nuance that bites later: the call is idempotent in outcome, not side-effect-free. Every completed check does write a row to your validation history, count against your monthly quota, and record usage on the key. What makes retrying safe isn’t that nothing happens – it’s that a failed check doesn’t leave that trail. On any transient error, vatnode refunds the reserved quota slot, so a retried 503 costs you nothing. And the checkId in each response is a fresh trace identifier for that one call, not a de-duplication handle – don’t try to use it as an idempotency key, because nothing reads it back.

Which failures to retry

Group the error codes by who owns the fix, and the retry policy falls out of it. This bucketing is an inference from how the API surfaces failures – it isn’t a retry taxonomy VIES publishes – but it maps cleanly onto the codes vatnode returns:

| Code | HTTP | Retry? | Why | | ------------------- | ---- | ----------------- | ---------------------------------------------------------------------- | | INVALID_FORMAT | 400 | No | Your input is malformed – the same bytes fail identically | | INVALID_REQUESTER | 422 | No | Your account’s requester VAT setting is bad – a config fix, not a wait | | RATE_LIMITED | 429 | Yes, long horizon | Monthly quota spent – resets with billing, not in seconds | | VIES_UNAVAILABLE | 503 | Yes, backoff | A member-state node or VIES itself is down | | VIES_ERROR | 502 | Yes, backoff* | An upstream protocol fault, not a verdict | | UPSTREAM_TIMEOUT | 504 | Yes, backoff | VIES accepted the request but didn’t answer in time | | INTERNAL_ERROR | 500 | Yes, backoff | An unexpected fault on our side |

*One exception: a VIES_ERROR can also arrive as HTTP 403 when VIES reports the identity as blocked (VAT_BLOCKED/IP_BLOCKED). That’s not transient – retrying just repeats the block – so treat a 403 as terminal even though the code is VIES_ERROR. The driver below branches on it explicitly.

The two No rows are the ones people get wrong under pressure. A 400 is a malformed string that never reached a national node – retrying identical bytes is pure waste; surface it to the user so they fix the typo. A 422 is not about the number you’re checking at all; it means the requester VAT number configured on your account (the one that unlocks consultation numbers) is itself invalid in VIES. No amount of backoff heals a config value – fix it in dashboard Account details.

Everything in the Yes block shares one rule: none of them is a verdict. A timeout tells you nothing about the VAT number – only that a system that could confirm it didn’t answer. Never persist a 502/503/504 as valid: false. Requeue the check, let the transaction proceed on your safe default, and backfill the real answer when the retry lands. The VIES downtime guide goes deeper on that queue design.

The backoff floor nobody expects

Here’s the detail that separates a working retry loop from one that quietly does nothing: vatnode caches a transient VIES failure.

When a 503 (VIES_UNAVAILABLE) or 504 (UPSTREAM_TIMEOUT) comes back for a given number, that failure is cached for 60 seconds, and the next call for the same number inside that window fast-fails from cache without contacting VIES at all. This is deliberate – it stops a hot key from hammering a national node that’s already struggling, and it’s the same short-TTL caching that keeps repeated checks fast and compliant. But it means a naive for loop retrying every 200 ms just replays the cached error five times and gives up, having never reached VIES on the second through fifth attempts.

Two scoping notes. This error cache applies to calls without a requester VAT configured; if your account sets a requester VAT to get consultation numbers, the cache is bypassed and every retry re-hits VIES, so the 60-second floor is moot for you. And it’s only these two ‘upstream is down/slow’ codes that get cached – a 502 protocol fault does not, so a retried 502 reaches VIES on every attempt. (A 502 still refunds your quota like any transient failure; it just isn’t the cached kind.)

So your backoff floor has to clear the cache window. Start above 60 seconds:

const ERROR_CACHE_TTL_MS = 60_000 // vatnode caches a transient 503/504 this long
const FLOOR_MS = Math.round(ERROR_CACHE_TTL_MS * 1.5) // stay above it so a retry re-contacts VIES
const MAX_ATTEMPTS = 5

// Exponential base with full jitter, floored above the error-cache window.
// Jitter spreads a fleet of workers so they don't retry in lockstep.
function backoffMs(attempt: number): number {
  const ceiling = FLOOR_MS * 2 ** attempt
  return FLOOR_MS + Math.floor(Math.random() * ceiling)
}

For a real integration the delays are usually minutes, not seconds – a VAT check almost always sits behind an async job (signup backfill, invoice run, nightly re-validation), not a blocking request the user is waiting on. Treating it that way is what makes retries forgiving: you’re never racing a spinner.

A retry driver

The driver below classifies the response, stops on the two terminal codes, defers on a quota wall, and backs off on everything upstream. Note what it never does: write a verdict on any path that didn’t get one.

type VatCode =
  | 'INVALID_FORMAT'
  | 'INVALID_REQUESTER'
  | 'RATE_LIMITED'
  | 'VIES_UNAVAILABLE'
  | 'VIES_ERROR'
  | 'UPSTREAM_TIMEOUT'
  | 'INTERNAL_ERROR'

type VatErrorBody = { error: { code: VatCode; message: string; requestId: string } }

// Input is wrong — a retry sends the same bad bytes. Fix it, don't wait.
const TERMINAL = new Set<VatCode>(['INVALID_FORMAT', 'INVALID_REQUESTER'])

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))

async function validateVat(vatId: string, attempt = 0): Promise<VatResult> {
  const res = await fetch(`https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`, {
    headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
  })

  if (res.ok) return (await res.json()) as VatResult

  const { error } = (await res.json()) as VatErrorBody

  // 400 / 422 — a wait changes nothing. Surface it and stop.
  if (TERMINAL.has(error.code)) {
    throw new TerminalVatError(error.code, error.message)
  }

  // 429 — monthly quota, not a seconds-scale blip. Hand it off; don't spin here.
  if (error.code === 'RATE_LIMITED') {
    throw new QuotaExhaustedError(res.headers.get('RateLimit-Reset'))
  }

  // 403 — a VIES_ERROR for a blocked IP or VAT number. Retrying repeats the
  // block, so treat it as terminal and alert rather than backing off.
  if (res.status === 403) {
    throw new BlockedError(error.code, error.requestId)
  }

  // 502 / 503 / 504 / 500 — no verdict was reached. Back off and try again.
  if (attempt >= MAX_ATTEMPTS) {
    throw new RetriesExhaustedError(error.code, error.requestId)
  }
  await sleep(backoffMs(attempt))
  return validateVat(vatId, attempt + 1)
}

Two things to pin down.

The RATE_LIMITED branch reads RateLimit-Reset, but treat that header as a quota-window signal, not a retry hint. It tells you when your monthly allowance rolls over – it is not ‘retry this one request in N seconds.’ Spinning on a 429 won’t help; defer the job, degrade to your default, or raise the ceiling. vatnode also sends RateLimit-Limit and RateLimit-Remaining on live responses so you can back off before you hit the wall, which is the better place to react. The rate-limits reference has the exact header contract. There is no Retry-After header on this API – don’t reach for one.

And the terminal-vs-retry split fails safe. If a code ever shows up that this switch doesn’t recognize, it lands in the backoff branch, not the ‘invalid’ branch – the worst case is a wasted retry, never a false negative written to your customer record. Failing toward ‘we don’t know yet’ is the only safe direction when the alternative is libelling a legitimate business as unregistered.

Test the retry path before you trust it

You don’t need VIES to actually be down to prove your backoff works. vatnode’s test keys return deterministic fixtures with no network call and no quota spent, and two of them are error fixtures made for exactly this: XX0000004 always returns VIES_UNAVAILABLE (503) and XX0000005 always returns VIES_ERROR (502). Point your retry driver at those with a vat_test_ key and you can unit-test that it backs off, exhausts, and never persists a verdict, all without touching production or a real tax authority. The test-mode docs list the full fixture set.

Retrying a batch

If you validate in bulk with POST /v1/vat/bulk, retries get simpler, not harder. Submitting is 202 Accepted: the call records the batch and validates nothing during the request, so there’s no batch-level failure to retry. One malformed VAT ID in a job of ten thousand can’t fail the submission – it can only fail its own position.

Per-position outcomes are still independent, but you read them off GET /v1/vat/bulk/{jobId}/results, not the submit response – the 202 returns a jobId. A position that failed carries an error object using the same code vocabulary as the single endpoint above, and valid: null – never false. A check that didn’t complete is not a negative result, and null is how bulk expresses ‘no result yet.’ So test valid === true or === false explicitly. A truthiness check on valid treats null the same as false, and that’s how the invariant gets violated in bulk code.

Don’t retry the whole job. Collect the positions whose error.code is retryable and resubmit just those VAT IDs – as a new job, since there’s no ‘next pass’ of one that’s already been accepted. One retryable code the table above omits applies here too: AUDIT_WRITE_FAILED means the check ran but its record couldn’t be stored. The position got no verdict and wasn’t billed, so resubmitting it is both safe and worth doing. De-duplication protects you here: vatIds is de-duplicated on intake with first-seen order preserved, so even a sloppy resubmission list becomes one position per number, billed once. The pattern for periodic re-validation over a customer base is in bulk re-validating customer VAT IDs.

The one rule underneath all of this

Every decision above collapses to a single invariant: a check that didn’t complete is not a negative result. Retry it, defer it, or fix your input – but write a verdict exactly once, when a real one comes back. Get that right and the rest is just choosing sensible delays. This is engineering guidance, not tax advice; how you treat a customer whose VAT you couldn’t verify in time is a call for your finance and compliance people.

FAQ

Is a VAT validation API call safe to retry?

Yes. The check is an HTTP GET – a lookup that changes nothing at VIES – so repeating it is safe and returns the same verdict as long as the number’s registration hasn’t changed. With vatnode there’s a second reason it’s safe – transient failures (503, 502, 504) refund the reserved quota slot, so a retry doesn’t cost you a request.

Do I need an idempotency key to call the VAT API?

No. Idempotency keys exist to make a repeated write safe – charge a card once, create one order. A VAT check is a read, so there’s nothing to de-duplicate and no idempotency-key header to send. Retry the GET directly.

How long should I wait before retrying a failed VAT check?

Longer than you’d think. vatnode caches a transient VIES failure for the same number for 60 seconds and fast-fails repeat calls in that window without contacting VIES, so a sub-second retry loop just replays the cached error. Start your backoff above 60 seconds so the retry actually reaches VIES again.

Should I retry a 429 rate-limited response?

Not on a short timer. A 429 means you’ve spent your monthly quota, which resets with your billing period – not in a few seconds. Defer the work, degrade to a safe default, or upgrade the plan. Spinning on a 429 just burns CPU against a wall that won’t move until the month rolls over.

One endpoint, structured codes, quota that refunds on failure

vatnode wraps VIES and its national fallbacks behind one VIES API that returns machine-readable error codes and refunds the quota slot on every transient failure – so your retry loop branches on code and never pays for a check that didn’t complete. Free plan, 100 requests/month.

Get a free API key