Caching VIES Responses Without Breaking Compliance

5 June 2026 · 7 min read

Caching VIES Responses Without Breaking Compliance

Caching VIES is easy. Defending the cache during an audit is harder. A single SOAP round-trip to VIES takes 1–3 seconds on a good day and 10+ seconds when a national node is under load, so if your checkout calls VIES synchronously for every B2B customer, you are leaving a lot of latency on the table. Caching is the obvious fix — but VAT validation is not the kind of cache you set to 24 hours and forget about. Get the TTL wrong and your audit trail stops being defensible.

This post walks through what you can safely cache, for how long, and how to keep the consultation number trail intact when you do.

Why caching VIES is different from caching anything else

A normal cache trades freshness for latency. VAT validation has a third axis: evidence. When a tax authority asks "did you check this customer's VAT number at the moment you reverse-charged the invoice?", you need to point at a timestamped record. A response you served from cache is a response you did not actually re-fetch from VIES — which is fine, as long as the cached record still represents what VIES would have said at the time of the transaction.

In practice this means two things:

  • The cached entry must include the original VIES timestamp and consultation number, not the cache-write time. If you re-serve a 6-hour-old response, the audit log must say it came from a check performed 6 hours ago — not "now".
  • The TTL must be short enough that you can defend it. There is no statute that says "you must re-check every N hours", but tax authorities will push back on caches measured in days when the underlying status can change overnight.

What VIES actually returns and what changes

A VIES response contains the validity flag, optional name and address, the request timestamp, and — if you pass a requester VAT ID — a consultation number that proves a specific party performed the check on a specific date. The validity flag is the only field that can flip without warning: a company can deregister, a number can be invalidated by the issuing authority, and there is no notification channel.

For SaaS billing, this matters at three moments:

  1. Signup / first invoice — you must check before applying reverse-charge for the first time.
  2. Recurring renewals — a number that was valid in January can be invalid by March.
  3. Audit reconstruction — months or years later, you must show that the check supporting each invoice was real.

The same axes apply outside SaaS — marketplaces, ERP integrations, bulk invoicing, and accounting platforms each have their own invoice-timing expectations, but the underlying "check, evidence, re-check" loop is the same.

A defensible TTL ladder

There is no single right number, but the pattern that holds up across EU tax authorities is a tiered TTL with explicit revalidation on the events that matter:

// Tiered cache strategy
const TTL = {
  // Same request inside one checkout flow / page load
  HOT: 15 * 60, // 15 minutes
  // Same customer interacting with your app the same day
  WARM: 24 * 60 * 60, // 24 hours
  // Hard ceiling — re-check before issuing the next invoice
  INVOICE_REVALIDATE: 30 * 24 * 60 * 60, // 30 days
}

The 15-minute hot tier covers retries and the multi-page checkout pattern (cart → address → payment) without hammering VIES. The 24-hour warm tier covers same-day dashboard interactions. The 30-day ceiling is the one that matters for compliance: there is no single statutory rule here, but many compliance teams treat ~30 days as a practical upper bound between the supporting VIES check and the issued B2B reverse-charge invoice. Re-validate before that horizon, store the new consultation number against the new invoice, and start the TTL again. See the VIES consultation number guide for the audit storage shape.

Negative caching: invalid IDs need a different TTL

The TTL ladder above assumes a valid: true response. Invalid results — VAT_NOT_FOUND, INVALID_INPUT, or a flat valid: false from VIES — should be cached more cautiously, often with a TTL closer to minutes than hours.

It helps to keep the two cache paths conceptually separate: the positive cache is evidence reuse (you are deliberately replaying a known-good check, with its consultation number, to avoid re-asking VIES the same question), while the negative cache is temporary failure suppression (you are throttling repeated client retries against a known-bad input, without committing to that result as audit evidence). Different purposes, different TTL discipline.

Two reasons. First, a freshly-registered taxable person may genuinely become valid within hours of their initial registration appearing in the national database; aggressive negative caching will repeatedly reject a legitimate customer who is trying again after registering. Second, an invalid result is rarely an invoice-critical event in the same way a positive result is — you are not building audit evidence for a transaction that did not happen, so the trade-off is mostly UX, not compliance. A reasonable default is 5–15 minutes for invalid, 24 hours for valid, with the 30-day re-validation ceiling applying only to the positive path.

Redis pattern: cache the response, not the decision

The wrong way to cache: store a boolean isValid: true keyed by VAT ID. The right way: store the full response object, including the original timestamp and consultation number, so that when you re-read it you reconstruct the full audit context.

import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })

type CachedVies = {
  vatId: string
  valid: boolean
  name?: string
  address?: string
  consultationNumber?: string
  checkedAt: string // ISO timestamp of the original VIES call
  source: 'VIES' | 'BZST'
}

async function getValidatedVat(vatId: string): Promise<CachedVies> {
  const key = `vat:${vatId.toUpperCase()}`
  const cached = await redis.get(key)

  if (cached) {
    const entry = JSON.parse(cached) as CachedVies
    // Refuse stale entries for invoice-critical paths
    const ageMs = Date.now() - new Date(entry.checkedAt).getTime()
    if (ageMs < 30 * 24 * 60 * 60 * 1000) {
      return entry
    }
  }

  const res = await fetch(
    `https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`,
    { headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` } },
  )

  if (!res.ok) {
    throw new Error(`VAT check failed: ${res.status}`)
  }

  const data = await res.json()
  const entry: CachedVies = {
    vatId: data.vatId,
    valid: data.valid,
    name: data.name,
    address: data.address,
    consultationNumber: data.consultationNumber,
    checkedAt: data.checkedAt,
    source: data.source,
  }

  // Positive results: 24-hour Redis TTL with the 30-day ceiling enforced above.
  // Negative results: short TTL, just enough to suppress retry storms.
  const ttl = entry.valid ? 24 * 60 * 60 : 10 * 60
  await redis.set(key, JSON.stringify(entry), { EX: ttl })
  return entry
}

Two details that are easy to miss:

  • Key on the normalised VAT ID (uppercase, no spaces). de123456789, DE 123 456 789, and DE123456789 are the same number — different cache keys is a memory leak and a correctness bug.
  • Persist the cached entry to your database too, not just Redis. Redis is for latency; PostgreSQL is for the audit trail. The two have different retention needs — the operational cache exists to make the next request fast and can be flushed at any time, while the evidence snapshot (the row tied to the invoice, with its consultation number and original VIES timestamp) is the artefact you must still be able to produce years later. Treat them as separate systems with separate lifecycles, even if they happen to start out with the same payload.

When to bypass the cache no matter what

Some events should always trigger a fresh VIES call, regardless of how warm the cache is:

  • Issuing a B2B reverse-charge invoice. The consultation number on that invoice must reflect a check performed close to the invoice date.
  • Customer-initiated VAT ID change. A new VAT ID always means a new check — never inherit validity from a previous number.
  • Subscription renewal that crosses a tax year. This is a best-practice heuristic rather than a written rule, but year-end is when most tax authorities reconcile filings — a check that sits cleanly inside the same reporting period as the invoice is easier to defend than one that straddles two.
  • The cached entry is missing a consultation number. If your previous check did not pass a requester VAT ID and you now need audit-grade evidence, the old entry is not good enough — re-run it.

The cleanest way to express this in code is to make the cache opt-in per call site, not the default:

// Fast paths — cache is fine
const result = await getValidatedVat(vatId)

// Compliance-critical paths — force a fresh check
const fresh = await getValidatedVat(vatId, { bypassCache: true })

Rate limits: a cache is not a license to hammer

Even with a healthy cache hit rate, you will still hit VIES often enough that rate limits matter. There is no published per-IP limit, but national nodes throttle aggressively under load — Germany's BZSt-backed node especially. Two patterns help:

  • Coalesce concurrent requests for the same VAT ID. If 50 parallel checkout sessions all want to validate DE123456789 and your cache is cold, you want exactly one outbound VIES call, not 50. A simple in-memory promise map per process is enough for a single instance; for horizontally scaled deployments, lift the same idea to a short-lived Redis lock (or any singleflight primitive) so the de-duplication holds across instances.
  • Queue background revalidations. Renewal checks, scheduled re-validations, and admin-triggered audits should never run on the request path. Use a job queue with concurrency capped at 2–4 to stay polite to VIES.

If you are already hitting VIES_UNAVAILABLE regularly, caching alone will not save you — the VIES downtime guide covers the retry-and-queue pattern that complements this one.

Stale-if-error and partial outages

VIES is not a single service — it is a thin EC gateway in front of national tax-authority nodes. At any given moment one or two country nodes may be unreachable while the rest respond normally. That asymmetry matters for cache design:

  • Stale-if-error. When VIES (or the relevant national node) is down, a recent cached response is usually preferable to a hard failure on checkout. Allow reads from a slightly-older cache during an outage — but log the stale serve explicitly and never reuse a stale entry as the audit-grade check for a freshly issued invoice. Stale-if-error is a UX safety net for the request path, not a compliance shortcut.
  • Country-scoped failure modes. If your retry/backoff logic treats every error the same, a single down country will spam your queue while the rest of the EU is healthy. Tracking the failure rate per country prefix lets you cool off only the affected nodes.
  • Proactive revalidation. For long-running subscriptions, do not wait for the cache to expire on the customer's next invoice — schedule a background re-check a few days before renewal. If the number is still valid, the cache is warm when the invoice runs. If it has gone invalid, you get a quiet email-the-customer window instead of a billing-time failure.

Summary

Cache VIES responses, but cache the full response with its original timestamp and consultation number, not a boolean. Keep the TTL short enough to be defensible (24 hours warm, 30 days hard ceiling), and bypass the cache on the events where evidence actually matters: invoice issuance, VAT ID changes, year boundaries. Coalesce in-flight requests, queue background work, and store the audit trail in your database — Redis is not where compliance lives. The goal is not to avoid VIES calls entirely; it is to minimise unnecessary calls without weakening the evidentiary chain behind the invoices that matter.

vatnode handles the cache layer for you

vatnode caches VIES responses internally with compliance-oriented TTL handling, exposes the consultation number on every response, and returns structured error codes so your app can implement the patterns above without re-implementing the SOAP client. Free plan, 100 requests/month.

Get Free API Key · Error Code Reference