Validate VAT Numbers at Signup Without Blocking

24 July 2026

Validate VAT Numbers at Signup Without Blocking

How to Validate VAT Numbers at Signup Without Blocking the Flow

You want a customer's VAT ID captured during signup or checkout, and you want it checked against VIES — but the check depends on an external system you do not control, and that system has no uptime guarantee. If you wire the check inline and block on it, a slow or unavailable VIES turns into a lost signup. That is the whole problem, and it has a clean shape: separate the parts of the check that are instant and local from the part that talks to the outside world, and never let the outside world stand between a real customer and a created account.

This is the pattern for doing that. The scope here is the EU-27 plus XI (Northern Ireland), the area VIES covers.

Why you should never block signup on a VAT check

A seller validates a buyer's VAT ID for a concrete reason. For intra-Community supplies of goods, the buyer's valid VAT ID is a substantive condition for exemption — since the 2020 Quick Fixes (Directive (EU) 2018/1910 amending Art 138 of the VAT Directive) it is not a formality. For cross-border B2B services, a valid buyer VAT ID supports reverse charge: the place of supply moves to the customer (Art 44) and the customer accounts for the VAT (Art 196). So the check matters. That is exactly why it is tempting to gate signup on it — and why that instinct is wrong.

The check has a dependency you do not own. VIES routes each query to a national database, and it has no SLA. National systems go into maintenance, VIES itself returns "service unavailable," calls time out. If your signup form awaits that call and treats anything short of a clean valid as a hard stop, then a real, VAT-registered customer gets rejected because a government system was down for twenty minutes. You have converted an infrastructure hiccup into churn.

There is also a subtler trap. A valid buyer VAT ID is one condition among several — for goods the items must physically leave the member state (with transport evidence), the place of supply has to be right, and you still need a correct recapitulative statement (EC Sales List) and correct invoicing. The VAT check alone never secures the treatment. So blocking signup on it buys you nothing legally while costing you customers. The check belongs in your pipeline; it does not belong on the critical path of account creation.

The three things a signup-time VAT check must do

Strip the problem down and a signup-time check has exactly three jobs:

  1. Reject obvious garbage instantly. If the string cannot possibly be a VAT ID for the country the user picked, say so inline, before any network call. This is a format check and it is local.
  2. Confirm the number is registered and active, asynchronously. "Registered and active" means checked against VIES at query time. A format check alone is insufficient — a string can match a country's pattern perfectly and still not be a real, active number. This step talks to the outside world, so it runs off the critical path.
  3. Record the outcome as evidence. Store what you sent, what came back, and the fields that make the check reproducible later. Auditors on intra-EU supplies may ask.

The mistake is collapsing steps 1 and 2 into one blocking call. They have different failure modes and different latencies. Keep them apart.

Pattern 1 — format-check inline, validate async

The format check is a regex against the country pattern. It is instant, it needs no network, and it catches the typo cases — a transposed digit, a wrong-length string, the German customer who pasted their local tax number instead of their VAT ID. Run it on the field, synchronously, and give immediate feedback.

// Inline, on blur or submit. No network, so it is safe to await.
const EU_VAT_PATTERNS: Record<string, RegExp> = {
  DE: /^DE\d{9}$/,
  FR: /^FR[A-Z0-9]{2}\d{9}$/,
  // ...one entry per supported country prefix, EL for Greece, XI for NI
}

function isPlausibleVatId(input: string): boolean {
  const cleaned = input.replace(/\s/g, '').toUpperCase()
  const prefix = cleaned.slice(0, 2)
  const pattern = EU_VAT_PATTERNS[prefix]
  return pattern ? pattern.test(cleaned) : false
}

A passing format check means "this is worth sending to VIES," nothing more. Do not tell the user their number is valid at this point — it is not confirmed. Tell them the shape is right and that you are verifying it.

The full validation goes to the API and does not block the response the user sees. Create the account, accept the VAT ID as pending, and fire the real check behind it.

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

  if (!res.ok) {
    // 503 VIES_UNAVAILABLE, 504 UPSTREAM_TIMEOUT, 429 RATE_LIMITED, etc.
    // Not a customer error — retry later, do not reject the signup.
    const { error } = await res.json()
    return { status: 'pending' as const, reason: error }
  }

  const data = await res.json()
  // { valid, vatId, countryCode, countryName, companyName,
  //   companyAddress, source, consultationNumber, checkId, verifiedAt }
  return { status: 'done' as const, result: data }
}

The distinction that makes this safe: a 4xx/5xx from an upstream problem is not the customer's fault. INVALID_FORMAT (400) you already prevented inline. VIES_UNAVAILABLE (503), UPSTREAM_TIMEOUT (504), VIES_ERROR (502) and RATE_LIMITED (429) all mean "try again," not "reject." Only a completed answer of valid: false is a genuine "this number is not registered."

If you want the full request/response shape, it is in the API reference, and the end-to-end walkthrough is in how to validate VAT numbers.

Pattern 2 — soft-block vs hard-block at checkout

At checkout the stakes feel higher because the VAT ID may change the price the customer pays — reverse charge means you do not add local VAT. So the question becomes: do you let them proceed before the check completes?

Hard-block versus soft-block is a product and UX choice, not a legal requirement. Both are defensible if you draw the line in the right place:

  • Soft-block (recommended default). Let checkout complete. Apply your VAT treatment based on the async result once it lands, and reconcile before you issue the invoice — the invoice is the document that has to be right, not the checkout screen. This keeps conversion intact and still lets you apply reverse charge on the invoice correctly.
  • Hard-block on a completed invalid. If, and only if, the check completes and returns valid: false, it is reasonable to gate the specific action that depends on it — refuse to apply reverse charge, ask the customer to re-enter the number, or fall back to charging VAT. You are gating the dependent action on a real answer, not on the check finishing.

The rule that holds in every case: never hard-block on VIES unavailability or a timeout. VIES has no SLA. If the answer has not arrived, that is a pending state, not a failure state. Gate on invalid, never on unknown.

function decideVatTreatment(check: { status: 'pending' | 'done'; result?: { valid: boolean } }) {
  if (check.status === 'pending') {
    // Proceed, charge VAT provisionally, reconcile before invoicing.
    return 'charge_vat_pending_recheck'
  }
  if (check.result?.valid) {
    // Substantive condition met — but not the only one. See below.
    return 'eligible_for_reverse_charge'
  }
  return 'charge_vat_invalid_id'
}

eligible_for_reverse_charge is deliberately named that way. A valid VAT ID makes the buyer eligible; it does not by itself finish the job. Background on the surrounding conditions is in VAT for B2B SaaS.

Handling VIES downtime without losing the customer

Downtime is the normal case, not the exception, so design for it. When the API returns VIES_UNAVAILABLE, UPSTREAM_TIMEOUT or VIES_ERROR, the customer's number goes into a pending state and onto a queue for re-validation. Surface the pending state honestly — "we are verifying your VAT ID" — instead of inventing a red error the customer cannot act on.

// Async queue sketch. Enqueue on signup; a worker drains it with backoff.
type PendingCheck = { customerId: string; vatId: string; attempts: number }

async function processPending(job: PendingCheck) {
  const check = await validateVatId(job.vatId)

  if (check.status === 'pending') {
    if (job.attempts >= 8) {
      await flagForManualReview(job.customerId, job.vatId)
      return
    }
    // Re-queue with exponential backoff; VIES tends to recover in minutes.
    await requeue(job, delayMs(job.attempts))
    return
  }

  await persistVatCheck(job.customerId, check.result)
}

Two things keep this working in practice: cap the retries so a genuinely dead number does not loop forever, and record every attempt — including the failed ones — so you can show you tried. The full treatment of retry windows, the terminal outcome, and what a national fallback changes is in the handle VIES downtime guide. One useful property of the API here: when VIES is unreachable for a given number, the answer may come from a national tax authority or company registry API instead, and source tells you which database actually answered.

Storing the result as audit evidence

A check is only useful later if you kept the evidence. Store the input, the outcome, and the fields that let you reproduce the check — the response gives you checkId (a stable identifier for the lookup), verifiedAt (the timestamp the check is valid as of), source (VIES, CACHE, or a national registry code), and consultationNumber.

The consultation number is the reference VIES issues on a requester-qualified answer. It is populated only when source is VIES and the lookup was requester-qualified; it is null otherwise — including on national-fallback answers, which do not produce one. It is not unique to any provider and it does not certify the number; it is a server-issued reference that ties a specific lookup to a moment in time, which is stronger evidence than your own logs alone.

async function persistVatCheck(
  customerId: string,
  data: {
    vatId: string
    valid: boolean
    source: string
    consultationNumber: string | null
    checkId: string
    verifiedAt: string
  }
) {
  await db.insert('vat_checks', {
    customer_id: customerId,
    vat_id: data.vatId,
    valid: data.valid,
    source: data.source,
    consultation_number: data.consultationNumber, // nullable
    check_id: data.checkId,
    verified_at: data.verifiedAt,
  })
}

Append, never overwrite — a check is valid only as of its timestamp, and the number's status can change later, so a fresh check is a new row, not an edit. The pattern for a durable trail is covered in keep an audit trail, and the consultation number specifically in the VIES consultation number explained.

Doing it with one API call

Everything above collapses into one request. The EU VAT validation API takes the VAT ID, runs the requester-qualified VIES call, falls back to a national source when VIES cannot answer, and returns a single JSON object with the outcome and the evidence fields already populated.

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

const data = await res.json()
// {
//   "valid": true,
//   "vatId": "DE123456789",
//   "countryCode": "DE",
//   "countryName": "Germany",
//   "companyName": "Example GmbH",
//   "companyAddress": "Musterstr. 1, ...",
//   "source": "VIES",
//   "consultationNumber": "WAPIAAAAX9999999",
//   "checkId": "chk_...",
//   "verifiedAt": "2026-07-24T08:30:00.000Z"
// }

You still keep the format precheck inline for instant feedback, and you still run this call off the critical path. But the downtime handling, the national fallback, and the consultation number all come back in one response instead of a SOAP envelope you parse yourself. You can get a free API key and wire it in against the shipped contract.

This is general information about EU VAT and VIES, not tax advice. Whether a specific transaction qualifies for zero-rating, exemption, or reverse charge depends on facts we can't assess here — confirm the treatment of your own transactions with a qualified tax adviser.

FAQ

Should VAT validation block a user from signing up?

No — validate asynchronously and let the account be created; only gate the specific action that depends on the result (for example applying reverse charge), and never lose the signup because VIES was momentarily unavailable.

What's the difference between a format check and a full validation at signup?

A format check confirms the string matches the country pattern and can run inline instantly; a full validation confirms the number is registered and active against VIES and should run asynchronously because it depends on an external system.

What happens if VIES is down when a customer signs up?

Accept the number, mark it pending, and re-validate on a queue; surface a clear pending state rather than rejecting a real customer over a temporary outage.

What should I store when I validate a VAT number at signup?

Store the input, the outcome, and the evidence fields the API returns — the VIES consultation number where one is issued, plus a check identifier and a verified-at timestamp — so the check is reproducible during an audit.

Validate VAT IDs from your own signup flow

The EU VAT validation API runs the requester-qualified VIES call, falls back to a national source on downtime, and returns the outcome plus the evidence fields in one response — no SOAP parsing. Get a free API key: free plan, 100 requests/month.