VAT Validation on Cloudflare Workers: Edge Proxy Guide

30 Oct 2026

VAT Validation on Cloudflare Workers: Edge Proxy Guide

This is a Cloudflare Worker that proxies vatnode: it holds the API key as a secret, accepts only same-origin calls that carry your session cookie, caches a narrow slice of answers with the Cache API, and maps every vatnode response and error code to a verdict the caller can act on. It assumes the Worker runs on your own domain, not on workers.dev, and that you already have a vatnode account.

Why a Worker, not a client-side call

Every vatnode request needs an Authorization: Bearer vat_live_... header. A browser that calls api.vatnode.dev directly ships that key inside the JavaScript it serves – readable in the network tab, readable in the bundle. CORS doesn’t rescue it either. vatnode restricts cross-origin requests to its own web app’s origin, so a browser call from a customer’s site is blocked – but by then the key is already exposed, and nothing stops someone from copying it into curl.

A Worker attached to a route on your own domain – example.com/api/vat/* rather than a workers.dev subdomain – is called by your page as part of its own origin, so the browser never talks to vatnode and the key never leaves Cloudflare. This particular Worker also gates who can call it, caches the answers that are safe to cache, and turns vatnode’s status and error codes into one small verdict shape. The API behind it is a full EU VAT validation API; its error contract is covered in handling VIES errors in code.

The secret and the wrangler config

The key is a secret, not a vars entry: Cloudflare’s own docs say not to put sensitive values in vars.

npx wrangler secret put VATNODE_API_KEY

Wrangler prompts for the value, stores it encrypted, and the Worker reads it off env.VATNODE_API_KEY. Locally, the same name goes in a .dev.vars or .env file next to the Wrangler config, kept out of source control. An optional secrets.required list makes a deploy fail fast if the secret was never set:

"secrets": { "required": ["VATNODE_API_KEY"] }

The rate limiter is a binding, configured once in the same config file:

"ratelimits": [
  { "name": "VAT_LIMITER", "namespace_id": "1001", "simple": { "limit": 10, "period": 60 } }
]

period is 10 or 60 seconds; limit is the number of calls allowed per key in that window. Attach the Worker to a route on a domain you control – the same-origin gate assumes it, and the Cache API section below depends on it:

"routes": [{ "pattern": "example.com/api/vat/*", "zone_name": "example.com" }]

No key yet? Register for a free plan: 100 requests a month, no card. Sign-up issues a test key straight away; create a live key in the dashboard when the Worker is ready to go live, and run wrangler secret put again with it.

The proxy route

The handler opens with two gates before it does anything that costs money or CPU:

if (request.method !== 'GET') return reply({ error: 'METHOD_NOT_ALLOWED' }, 405)
if (request.headers.get('Sec-Fetch-Site') !== 'same-origin') {
  return reply({ error: 'FORBIDDEN' }, 403)
}

Browsers send Sec-Fetch-Site on every fetch, with the value same-origin, same-site, cross-site or none depending on where the calling page lives. Only same-origin passes this gate, so a www. page calling the apex domain, or a frontend dev server on a different port, gets turned away too. Next, the session and the limiter:

const sid = sessionId(request)
if (!sid) return reply({ error: 'NO_SESSION' }, 401)
const { success } = await env.VAT_LIMITER.limit({ key: sid })
if (!success) return reply({ error: 'TOO_MANY_CHECKS' }, 429)

sessionId reads a sid cookie your own backend issues – the limiter keys on it rather than on an IP address, which Cloudflare’s own rate-limiting binding docs advise against for per-visitor accounting. No sid cookie means a straight rejection, before the limiter or vatnode.

Past the gates, the path becomes a VAT ID and gets checked against a shape before any network call:

const url = new URL(request.url)
const vatId = vatIdFrom(url)
if (!VAT_ID_SHAPE.test(vatId)) return reply({ state: 'invalid', reason: 'INVALID_FORMAT' })

const cache = caches.default
const cacheKey = new Request(`${url.origin}/__vat-cache/${vatId}`)
const hit = await cache.match(cacheKey)
if (hit) {
  const cached = (await hit.json()) as VatnodeResult
  return reply(toVerdict(200, cached), 200, { 'X-Edge-Cache': 'HIT' })
}

Only a cache miss reaches vatnode:

let upstream: Response
let body: VatnodeResult | VatnodeError
try {
  upstream = await fetch(VATNODE_URL + encodeURIComponent(vatId), {
    headers: { Authorization: `Bearer ${env.VATNODE_API_KEY}` },
    signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
  })
  body = (await upstream.json()) as VatnodeResult | VatnodeError
} catch {
  return reply({ state: 'unavailable', code: 'WORKER_FETCH_FAILED' })
}

UPSTREAM_TIMEOUT_MS is 20 seconds, well above the 12-second timeout vatnode sets on a single VIES call. vatnode’s own retry and fallback logic can still make a full check take longer, so if the Worker’s timeout fires first it reports WORKER_FETCH_FAILED – a statement about the Worker, not about the VAT number. The check may still complete on vatnode’s side and count against quota. Waiting on fetch does not count against a Worker’s CPU time limit, so even the Workers Free plan’s 10 ms CPU limit is not a concern here; what is left once the fetch resolves is a regex and a couple of JSON parses. A body that fails to parse as JSON – an upstream HTML error page, say – throws inside that try and comes back as WORKER_FETCH_FAILED, same as a network failure or a timeout.

Mapping vatnode responses

valid

  • 200 with valid: true

invalid

  • 200 with valid: false
  • 400 INVALID_FORMAT

unavailable, code CONFIG – logged server-side, never shown to the customer as their mistake

  • 401 UNAUTHORIZED, 401 INVALID_API_KEY
  • 422 INVALID_REQUESTER

unavailable

  • 429 RATE_LIMITED – the monthly quota is used up; it is not a per-second limit, so don’t retry it in a loop
  • 502 VIES_ERROR, 403 VIES_ERROR, 503 VIES_UNAVAILABLE, 504 UPSTREAM_TIMEOUT
  • 502 AUDIT_WRITE_FAILED, 500 INTERNAL_ERROR
  • a Worker fetch failure, a timeout, or a non-JSON body

VIES_UNAVAILABLE is never invalid – it means the check did not complete, not that the number failed. A 401 or 422 is a fault in how the Worker is configured, not in the customer’s input; the code logs it and keeps the customer-facing message generic, because telling a customer their VAT number is wrong when the real problem is a revoked API key sends them chasing the wrong fix.

Two different 429s

Two rate limits are in play, and they mean different things. The Worker’s own TOO_MANY_CHECKS fires when one session has called the edge too many times inside the configured window – a guard on the proxy, cheap to tune, reset every window. vatnode’s RATE_LIMITED means the account’s monthly quota is gone, and the Worker does not surface that as its own 429 at all – it comes back through toVerdict as an unavailable state inside a normal 200 response, same bucket as a VIES outage. Retrying a monthly-quota exhaustion in a loop just burns more of the Worker’s own request budget; the fix is a plan with more quota or waiting for the next billing period, not a retry.

Caching with the Cache API

The Worker reaches for the Cache API – caches.default – rather than Cloudflare’s general Workers Caching, because it needs to decide per response whether an answer is safe to cache at all, which Workers Caching does not give it:

// Cache only a positive answer that carries no consultation number,
// never a test fixture, and never past vatnode's own 15-minute window.
if (
  upstream.status === 200 &&
  'valid' in body &&
  body.valid &&
  body.consultationNumber === null &&
  !vatId.startsWith('XX')
) {
  const ttl = edgeTtl(body)
  if (ttl > 0) {
    const toStore = new Response(JSON.stringify(body), {
      headers: { 'Content-Type': 'application/json', 'Cache-Control': `max-age=${ttl}` },
    })
    ctx.waitUntil(cache.put(cacheKey, toStore))
  }
}

Only a valid answer with no consultation number and a real, non-fixture VAT ID is a candidate. vatnode sets Cache-Control: no-store on its own responses, and Cloudflare’s Cache API docs say cache.put() returns a 413 error when a response’s Cache-Control says not to cache it. So the Worker never puts the original; it builds a fresh Response with its own max-age instead. That TTL is not a fixed number: edgeTtl subtracts the age of verifiedAt from vatnode’s own 15-minute cache window, so the edge never holds an answer longer than vatnode would have held it itself.

function edgeTtl(result: VatnodeResult): number {
  const ageSeconds = (Date.now() - Date.parse(result.verifiedAt)) / 1000
  return Math.floor(VATNODE_CACHE_SECONDS - ageSeconds)
}

Three more things about Cache API storage: it is per data center with no replication, so the same VAT ID can be a miss in one region and a hit in another; it has no effect inside the dashboard editor or Playground; and an invalid answer or any error response is never cached – a negative answer is exactly what someone re-checks right after fixing a registration, and an error is transient, so caching either would serve a stale wrong answer for up to fifteen minutes.

The limiter runs before the cache lookup, so a cache hit still counts against the per-session limit. What it saves is the call to vatnode itself, and that is where the quota benefit is: a hit on vatnode’s own cache returns source: CACHE and is still billed against the account’s monthly quota. An edge cache hit never reaches vatnode at all, so it spends nothing.

The guards are speed bumps, not cost control

None of the checks in this Worker are strong enough to rely on as a spending cap. Sec-Fetch-Site is a header a real browser sets automatically, but nothing stops a script outside a browser from setting the same header by hand. The sid check only confirms a cookie is present – it does not verify the value was actually issued by your own backend, so anyone who loads the page once, or simply invents a new cookie each time, gets a fresh limiter bucket. If the limit needs to hold under that kind of abuse, the session has to be verified, a signed value or a lookup against sessions your backend created, before it reaches limit().

The rate-limiting binding itself is, by Cloudflare’s own description, per Cloudflare location, permissive, and eventually consistent rather than an exact accounting system – fine for shaping traffic, not a hard wall. The real spending limit sits one layer up, inside vatnode: on the free plan the hard stop is 100 requests a month with no overage; on a paid plan, overage is billed per request with no cap, so an open proxy in front of a paid key can run up a bill. If that risk matters, key the limiter on a signed-in user id or an issued checkout session instead of a bare cookie, and watch usage in the dashboard rather than assuming the Worker’s own checks are doing that job.

Test mode at the edge

A vat_test_ key only accepts VAT IDs that start with XX; any other shape returns 400 INVALID_FORMAT, which the Worker maps as invalid – the same as a real format failure. Worth knowing before a demo: a real-format German ID sent with a test key does not come back as a fixture, it comes back as invalid, because vatnode rejects any non-XX ID in test mode before doing anything else.

The documented fixtures cover the whole verdict space with no VIES call and no quota spent: XX0000001 returns valid with a company name, XX0000003 returns invalid, XX0000004 and XX0000005 return unavailable states standing in for a VIES outage and a VIES error. None of them get cached – the caching rule excludes anything starting with XX – so every fixture call reaches the full handler logic fresh, which is what makes them useful for exercising the gates, the limiter, and the response mapping before switching to a live key. More fixtures and the full contract are in vatnode API test keys and test mode.

Edge-cached verdict is UX, not audit evidence

A verdict served from the edge cache never touched vatnode on that request – no new check, no check id, nothing added to the account’s vatnode check history. Fine for showing a customer that their number checks out, but not a record that stands on its own if the verdict ever needs to be defended later.

The caching rule already protects the case that matters most: any answer carrying a consultation number is never cached. On a vatnode account with a requester VAT configured, every valid answer carries a consultation number, or the check fails with an error instead. That number belongs to one specific check, so caching it would mean presenting one check’s consultation number as the record of another. For why that number exists and what it is worth as evidence, see the VIES consultation number explained; for the broader tradeoff this Worker implements a narrow slice of, see caching VIES responses for compliance.

Re-derive the verdict with a fresh server-side call at the point it has to be right – order submission, or invoicing – rather than trusting whatever the edge last served. This is not tax or legal advice; it is a statement about where the record of a check comes from.

Same pattern on other serverless platforms

Nothing above is specific to Cloudflare except the exact APIs. The same rules hold on any serverless or edge platform running a proxy in front of vatnode: keep the key in whatever that platform calls a secret, never in an environment variable that ends up in a client bundle; set the outbound timeout generously above vatnode’s 12-second single VIES call, since retries and fallbacks can make a full check longer, and treat your own timeout as unavailable, not as a failed check; and never cache an answer that carries a consultation number or an error – a negative result and a transient failure both need a fresh call. unavailable is still not invalid wherever the proxy runs.

Full Worker code

// src/index.ts — runs on a route such as example.com/api/vat/*
export interface Env {
  VATNODE_API_KEY: string
  VAT_LIMITER: RateLimit
}

type VatVerdict =
  | { state: 'valid'; companyName: string | null }
  | { state: 'invalid'; reason: string }
  | { state: 'unavailable'; code: string }

interface VatnodeResult {
  valid: boolean
  companyName: string | null
  verifiedAt: string
  consultationNumber: string | null
}

interface VatnodeError {
  error: { code: string; message: string; requestId: string }
}

const VATNODE_URL = 'https://api.vatnode.dev/v1/vat/'
const UPSTREAM_TIMEOUT_MS = 20_000
const VATNODE_CACHE_SECONDS = 15 * 60
const VAT_ID_SHAPE = /^[A-Z]{2}[A-Z0-9+*]{2,13}$/
const CONFIG_ERRORS = new Set(['UNAUTHORIZED', 'INVALID_API_KEY', 'INVALID_REQUESTER'])

function reply(body: unknown, status = 200, extra: HeadersInit = {}): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...extra },
  })
}

function sessionId(request: Request): string | null {
  const cookie = request.headers.get('Cookie') ?? ''
  const match = cookie.match(/(?:^|;\s*)sid=([^;]+)/)
  return match ? match[1] : null
}

function vatIdFrom(url: URL): string {
  let raw = url.pathname.split('/').pop() ?? ''
  try {
    raw = decodeURIComponent(raw)
  } catch {
    return ''
  }
  return raw.replace(/[\s.-]/g, '').toUpperCase()
}

function toVerdict(status: number, body: VatnodeResult | VatnodeError): VatVerdict {
  if (status === 200 && 'valid' in body) {
    return body.valid
      ? { state: 'valid', companyName: body.companyName }
      : { state: 'invalid', reason: 'NOT_REGISTERED' }
  }
  const code = 'error' in body ? body.error.code : 'UNKNOWN'
  if (code === 'INVALID_FORMAT') return { state: 'invalid', reason: code }
  if (CONFIG_ERRORS.has(code)) {
    console.error('vatnode configuration error', code)
    return { state: 'unavailable', code: 'CONFIG' }
  }
  // VIES_UNAVAILABLE, VIES_ERROR, UPSTREAM_TIMEOUT, AUDIT_WRITE_FAILED,
  // RATE_LIMITED (monthly quota), INTERNAL_ERROR: the check did not complete.
  return { state: 'unavailable', code }
}

// Seconds left before this answer is older than vatnode's own 15-minute cache.
function edgeTtl(result: VatnodeResult): number {
  const ageSeconds = (Date.now() - Date.parse(result.verifiedAt)) / 1000
  return Math.floor(VATNODE_CACHE_SECONDS - ageSeconds)
}

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    if (request.method !== 'GET') return reply({ error: 'METHOD_NOT_ALLOWED' }, 405)
    if (request.headers.get('Sec-Fetch-Site') !== 'same-origin') {
      return reply({ error: 'FORBIDDEN' }, 403)
    }

    const sid = sessionId(request)
    if (!sid) return reply({ error: 'NO_SESSION' }, 401)
    const { success } = await env.VAT_LIMITER.limit({ key: sid })
    if (!success) return reply({ error: 'TOO_MANY_CHECKS' }, 429)

    const url = new URL(request.url)
    const vatId = vatIdFrom(url)
    if (!VAT_ID_SHAPE.test(vatId)) return reply({ state: 'invalid', reason: 'INVALID_FORMAT' })

    const cache = caches.default
    const cacheKey = new Request(`${url.origin}/__vat-cache/${vatId}`)
    const hit = await cache.match(cacheKey)
    if (hit) {
      const cached = (await hit.json()) as VatnodeResult
      return reply(toVerdict(200, cached), 200, { 'X-Edge-Cache': 'HIT' })
    }

    let upstream: Response
    let body: VatnodeResult | VatnodeError
    try {
      upstream = await fetch(VATNODE_URL + encodeURIComponent(vatId), {
        headers: { Authorization: `Bearer ${env.VATNODE_API_KEY}` },
        signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
      })
      body = (await upstream.json()) as VatnodeResult | VatnodeError
    } catch {
      return reply({ state: 'unavailable', code: 'WORKER_FETCH_FAILED' })
    }

    // Cache only a positive answer that carries no consultation number,
    // never a test fixture, and never past vatnode's own 15-minute window.
    if (
      upstream.status === 200 &&
      'valid' in body &&
      body.valid &&
      body.consultationNumber === null &&
      !vatId.startsWith('XX')
    ) {
      const ttl = edgeTtl(body)
      if (ttl > 0) {
        const toStore = new Response(JSON.stringify(body), {
          headers: { 'Content-Type': 'application/json', 'Cache-Control': `max-age=${ttl}` },
        })
        ctx.waitUntil(cache.put(cacheKey, toStore))
      }
    }

    return reply(toVerdict(upstream.status, body), 200, { 'X-Edge-Cache': 'MISS' })
  },
} satisfies ExportedHandler<Env>

Next steps

FAQ

Does running the proxy on Cloudflare Workers make VAT validation faster? No. Most of the time a fresh check takes is the VIES lookup behind vatnode, and moving the proxy to the edge does not change that. What changes is that the vatnode API key never reaches the browser, and a repeat valid answer for the same VAT ID can be served straight from the edge cache instead of making a new request to vatnode.

What is the difference between the Worker rate limit error and the rate limit error from vatnode? The Worker has its own limiter that returns an error when one session sends too many requests through the edge in a short window, which is a guard on the proxy itself. The rate limit set inside vatnode means the account has used its full monthly quota, which is a separate limit. The Worker reports that second case as an unavailable state inside a normal response, not as its own too many requests error.

Can an answer served from the edge cache be used as audit evidence? No. A cache hit at the edge never reaches vatnode, so it creates no new check and no check id in the vatnode history. It also only happens for accounts without a requester VAT set, because any check that carries a consultation number is never cached. For anything that has to stand up as evidence, call vatnode again at the point the record is actually needed, such as order submission or invoicing.

Does the Cache API work in the dashboard editor or Playground? No. The Cache API has no effect when a Worker runs inside the Cloudflare dashboard editor or Playground. It only behaves as described on a Worker attached to a real route on a domain.

Can this be built and tested with a test key? Yes. A test key only accepts VAT ids that start with the letters XX, returns a fixed fixture response for each one, and never calls VIES or spends quota. The Worker in this post never caches those fixture answers, so the proxy, the limiter and the error mapping can all be exercised safely before switching to a live key.