Validate VAT Numbers at Checkout, Not Just at Signup
7 September 2026

Validate VAT Numbers at Checkout, Not Just at Signup
A VAT ID field at checkout has one job: help you decide, before payment, whether this specific order should carry VAT or not. That's a different problem from validating a VAT ID at signup — signup is about a stored customer attribute checked once, off the critical path of account creation. Checkout is about a single order total, computed live, often for a guest who has no account and never will — the cart-level eligibility decision this post is about.
The scope is EU-27 plus XI (Northern Ireland — goods only via VIES), intra-Community B2B only — no OSS, no distance-selling thresholds, no VAT calculation or filing.
What the VAT ID field at checkout is actually deciding
A VAT ID entered at checkout is evidence for one of two different legal questions, depending on what's in the cart:
- Goods. Since the 2020 Quick Fixes, a valid buyer VAT ID — plus a correct entry on your EC Sales List — is a substantive condition for zero-rating an intra-Community supply of goods (Council Directive (EU) 2018/1910, amending Art 138 of the VAT Directive). This is exemption, not reverse charge — the terms aren't interchangeable. Art 138(1a) is rebuttable ("unless the supplier can duly justify his shortcoming"), so a missing or invalid VAT ID isn't necessarily fatal to the treatment, but it removes your cleanest path to it. And a valid VAT ID alone never finishes the job for goods: the items still have to physically leave the dispatch member state, with transport evidence — the rebuttable presumption mechanism is Art 45a of Implementing Regulation 282/2011, inserted by Reg (EU) 2018/1912.
- Services. This is the actual reverse charge: the place of supply moves to the customer (Art 44) and the customer self-accounts for the VAT (Art 196). Your usable basis for treating the customer as a taxable person is Implementing Regulation (EU) 282/2011 Art 18(1)(a): the customer communicated a VAT ID, and you obtained confirmation of its validity and its associated name and address. VIES doesn't return a name and address for German or Spanish numbers, so full Art 18(1)(a) evidence for those two needs a source beyond VIES.
Neither rule says "run a VIES check." The statute requires the customer to hold and communicate a valid VAT ID; VIES is the evidentiary tool that shows you obtained confirmation in good faith (Reg 904/2010 Art 31). That distinction matters for how you build the checkout flow: the check is evidence-gathering, not a legal gate by itself, so it shouldn't block the transaction it's supposed to support. Background on the two regimes side by side is in VAT reverse charge and the do-I-charge-VAT reference.
Why checkout is a harder problem than signup
At signup, a pending VAT check just means the account is created and the field says "verifying." At checkout, the VAT ID can change the number on the payment page the customer is about to authorize — so the wrong instinct is stronger: block payment until the check comes back clean.
Don't. VIES has no SLA. An outage code — VIES_UNAVAILABLE, UPSTREAM_TIMEOUT, VIES_ERROR — means "VIES didn't answer, try again," not "not registered." A RATE_LIMITED or INVALID_REQUESTER means your own quota or requester config is the problem: still not a verdict on the buyer, and not something a retry fixes. None of them is a reason to block the buy button — doing so turns a national tax authority's maintenance window, or your own misconfiguration, into your abandoned-cart number. The only answer that means "not currently VAT-registered" is a check that completed with valid: false. A check that hasn't completed yet is not that answer.
The fix is the same shape as at signup, applied to the order total instead of the account: format-check the field inline, run the real check server-side without blocking payment, and gate only the specific decision — zero-rate this order or not — on a completed answer.
Guest checkout: the VAT ID is a one-time order field, not a customer record
Most e-commerce checkout has no signup pattern to reuse in the first place: the buyer is a guest, there's no account row to attach a "pending" flag to, and the VAT ID exists only for the duration of this order. Design around that:
- Store the check result on the order, not on a customer entity that may not exist tomorrow.
- Don't defer the decision to "next time they log in" — there is no next time for a guest.
- The order is also the audit unit later, so the evidence (the response fields below) needs to live wherever the order record lives, immutably, once the order is placed.
A checkout handler, server-side
The API key is a bearer credential — call it from your server, never from the browser. This is a Node.js handler (an API route or server action) that does the three things checkout needs: reject garbage inline, call the API server-side, and map every possible outcome — including the outage codes — to a VAT decision.
// checkout/vat-decision.ts — server-side only. Never call this from the browser.
const EU_VAT_PATTERNS: Record<string, RegExp> = {
DE: /^DE\d{9}$/,
FR: /^FR[A-HJ-NP-Z0-9]{2}\d{9}$/,
NL: /^NL\d{9}B\d{2}$/,
// ...one entry per country you support at checkout, EL for Greece
}
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
}
type VatDecision =
| 'no_vat_id' // field left blank — charge VAT, no decision to make
| 'eligible_for_exemption' // completed valid:true — one condition met, not the only one
| 'invalid_vat_id' // completed valid:false or bad format — charge VAT, ask to re-enter
| 'pending_recheck' // VIES didn't answer — charge VAT provisionally, reconcile before invoicing
| 'blocked_config' // your own quota/requester problem — charge VAT, alert ops, don't retry
async function decideVatTreatment(rawVatId: string | null): Promise<{
decision: VatDecision
checkId?: string
consultationNumber?: string | null
}> {
if (!rawVatId) return { decision: 'no_vat_id' }
if (!isPlausibleVatId(rawVatId)) {
// INVALID_FORMAT — caught inline, no network call
return { decision: 'invalid_vat_id' }
}
const cleaned = rawVatId.replace(/\s/g, '').toUpperCase()
let res: Response
try {
res = await fetch(`https://api.vatnode.dev/v1/vat/${cleaned}`, {
headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
signal: AbortSignal.timeout(8000),
})
} catch {
// Network failure or timeout — the same "no answer" class as an outage.
// Never let a hanging request block the checkout.
return { decision: 'pending_recheck' }
}
if (!res.ok) {
const { error } = await res.json()
switch (error.code) {
case 'VIES_UNAVAILABLE':
case 'UPSTREAM_TIMEOUT':
case 'VIES_ERROR':
case 'INTERNAL_ERROR':
// VIES didn't answer — retryable. Charge VAT now, reconcile later.
return { decision: 'pending_recheck' }
case 'INVALID_FORMAT':
// Slipped past the inline check (a looser regex than the API's).
return { decision: 'invalid_vat_id' }
case 'RATE_LIMITED':
case 'INVALID_REQUESTER':
// Your own quota or requester config, not the buyer's — a retry won't
// fix these. Charge VAT and alert ops; don't queue them for recheck.
return { decision: 'blocked_config' }
default:
return { decision: 'pending_recheck' }
}
}
const data = await res.json()
// { valid, vatId, countryCode, countryName, companyName, companyAddress,
// checkId, verifiedAt, source, consultationNumber }
if (data.valid) {
return {
decision: 'eligible_for_exemption',
checkId: data.checkId,
consultationNumber: data.consultationNumber,
}
}
return { decision: 'invalid_vat_id', checkId: data.checkId }
}
eligible_for_exemption is named that way on purpose. A valid: true result is one condition met, not clearance to zero-rate the order — for goods you still need the transport evidence, a correct EC Sales List entry, and the buyer VAT-identified in a member state other than where dispatch begins (Art 138(1)(b)): a valid same-country ID is a domestic supply, not an exempt intra-Community one. For services you still need the place-of-supply analysis to actually put the customer in scope of the reverse charge. Route the decision, don't shortcut it:
function applyVatDecision(decision: VatDecision, order: { netTotal: number; vatRate: number }) {
switch (decision) {
case 'eligible_for_exemption':
// For goods, also confirm the buyer is VAT-identified in a member state
// other than where dispatch begins (Art 138(1)(b)) before zero-rating —
// a same-country valid ID is a domestic supply. Recalculate from net; do
// not derive this by subtracting VAT from a gross figure already shown.
return { total: order.netTotal, vatApplied: false, needsReconciliation: false }
case 'pending_recheck':
return {
total: order.netTotal * (1 + order.vatRate),
vatApplied: true,
needsReconciliation: true, // reconcile before the invoice is issued
}
case 'blocked_config':
// Your validation is broken, not the buyer's number. Charge VAT so the
// order completes, and alert ops — this needs a human, not a retry.
return {
total: order.netTotal * (1 + order.vatRate),
vatApplied: true,
needsReconciliation: false,
needsOps: true,
}
case 'invalid_vat_id':
case 'no_vat_id':
return {
total: order.netTotal * (1 + order.vatRate),
vatApplied: true,
needsReconciliation: false,
}
}
}
The safe default on an outage is the seller's own domestic VAT of the dispatch member state — not the buyer's country rate. Charging the buyer's-country rate is an OSS/distance-selling mechanism for a different scenario and is out of scope here; applying it as a fallback for a B2B intra-Community order is the wrong number.
Recalculating the total when the VAT ID lands late
The awkward UX case: the customer sees a VAT-inclusive total, then pastes a VAT ID mid-checkout. Two mistakes are common here:
- Subtracting VAT from the displayed gross figure instead of recalculating from net — rounding differences between the two make the invoice not match what the customer thinks they agreed to pay.
- Re-running the check against whatever was in the field when the page first loaded instead of the final value the customer submits — a check against a half-typed ID doesn't cover the order.
Recompute from netTotal, and re-run decideVatTreatment against the exact string that's about to be submitted, not a cached earlier attempt.
Handling the outage case honestly
pending_recheck isn't a dead end, it's a queue entry. Charge VAT provisionally per the decision above, let the order complete, and reconcile before the invoice goes out — the invoice is the document that has to be right, not the checkout screen.
async function reconcileBeforeInvoicing(order: { id: string; vatId: string }) {
const { decision, checkId, consultationNumber } = await decideVatTreatment(order.vatId)
if (decision === 'eligible_for_exemption') {
await markOrderZeroRated(order.id, { checkId, consultationNumber })
}
// else: the provisional VAT-inclusive charge stands — nothing to reverse
}
A national tax authority or company registry can sometimes answer even when VIES itself is unavailable — see coverage for which countries have that fallback. When one does answer, source reports the registry that responded and consultationNumber is null, because national answers don't produce one; only a requester-qualified VIES answer does. Design your reconciliation job to treat both the same way operationally — it's the valid value that drives the invoice, not which source produced it. The retry-window and backoff mechanics for the outage case itself are covered in the VIES downtime guide, and the full error-code-to-behavior mapping is in handling VIES errors in code.
What to store per order
The evidence has to attach to the order, not float in a separate log you'd have to cross-reference later:
async function persistOrderVatCheck(
orderId: string,
data: {
vatId: string
valid: boolean
source: string
consultationNumber: string | null
checkId: string
verifiedAt: string
}
) {
await db.insert('order_vat_checks', {
order_id: orderId,
vat_id: data.vatId,
valid: data.valid,
source: data.source,
consultation_number: data.consultationNumber, // nullable
check_id: data.checkId,
verified_at: data.verifiedAt,
})
}
Once the order is placed, this row is append-only — a re-check produces a new row, never an edit to the one taken at checkout. The consultation number, where one exists, is the European Commission's own reference tying a specific requester-qualified lookup to a moment in time — useful evidence, not proof of the buyer's eligibility by itself. More on what it does and doesn't certify: the VIES consultation number explained.
One call for the whole checkout flow
The EU VAT validation API runs the VIES call, falls back to a national source when VIES can't answer, and returns the outcome plus the evidence fields in one response — no SOAP parsing, no separate registry integration to maintain. Set your own VAT as the requester in dashboard Settings and each fresh VIES answer also carries a consultation number; leave it unset and you keep the national fallback instead. The two are a config choice, not both at once — a requester-qualified call has no fallback, so a VIES outage there surfaces as one of the error codes above, which your pending_recheck path already handles. More on that trade-off in the VIES consultation number explained.
const res = await fetch('https://api.vatnode.dev/v1/vat/FR40123456824', {
headers: { Authorization: `Bearer ${process.env.VATNODE_API_KEY}` },
})
const data = await res.json()
// {
// "valid": true,
// "vatId": "FR40123456824",
// "countryCode": "FR",
// "countryName": "France",
// "companyName": "Example SAS",
// "companyAddress": "1 rue de Rivoli, 75001 Paris",
// "source": "VIES",
// "consultationNumber": "WAPIAAAAX9999999", // null unless a requester VAT is set
// "checkId": "019d2a89-a5d9-7b97-b710-57b84604de2b",
// "verifiedAt": "2026-09-07T08:30:00.000Z"
// }
For a French buyer, that's the France VAT API reference; the same shape applies across every supported country under /vat-api. If you're building into WooCommerce rather than a custom checkout, the WooCommerce VAT validation plugin wires the same check into the order flow without custom code. For a Stripe-based checkout, Stripe VAT validation covers that integration point. To re-check many pending orders at once, POST /v1/vat/bulk takes up to 100 IDs and returns the verdict, source and consultationNumber per item — it doesn't carry the full per-order evidence fields (checkId, verifiedAt), so keep using the single call where you need those. See the API reference and the error reference for the full code list.
This is general information about EU VAT and VIES, not tax advice. Whether a specific order qualifies for zero-rating or reverse charge depends on facts we can't assess here — dispatch location, transport evidence, the buyer's actual status — confirm the treatment of your own transactions with a qualified tax adviser.
FAQ
Should a VAT ID field block checkout if it can't be validated instantly?
No — the VIES call is server-side and has no SLA, so waiting on it before allowing payment turns a temporary outage into a lost order. Let checkout complete, apply a provisional VAT treatment, and reconcile before the invoice is issued.
Is validating a VAT ID at checkout about reverse charge or VAT exemption?
It depends on what's in the cart. For goods, a valid buyer VAT ID is a substantive condition for zero-rating the intra-Community supply. For services, it supports treating the customer as a taxable person under the reverse charge, where the customer self-accounts for VAT. They're different regimes with different legal bases — don't treat "reverse charge" as the umbrella term for both.
What happens if a customer enters a valid VAT ID after the order total is already shown?
Recalculate the order total from net, not by subtracting VAT from the gross figure already shown, and re-run the check against the final VAT ID before payment — a validation done against an earlier or partial number doesn't cover the order.
Do I need to re-check the VAT ID before issuing the invoice?
Yes if the checkout answer was provisional — a pending state from a VIES outage, or a check that ran before the customer finished editing the field. If checkout already returned a completed, valid answer for the exact ID on the order, that check is your evidence; you don't need to repeat it just because time has passed.
Validate VAT IDs from your own checkout flow
The EU VAT validation API runs the 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.