How to Validate EU VAT Numbers: A Developer's Guide
How to validate EU VAT numbers in your application — from client-side format checks to live registry lookup, handling upstream unavailability, and building the audit trail your accountant will ask for.
What VAT number validation is and why it matters
Every EU-registered business receives a VAT identification number — for example IE6388047V for Google Ireland or DE134214316 for a German company. Validating that number means confirming two things: its format is correct for the country, and it is currently active in the EU VAT registry.
For developers building B2B SaaS or e-commerce platforms, this matters at the point of invoicing. When a VAT-registered business in one EU member state supplies services to a VAT-registered buyer in another, the transaction is typically subject to the reverse charge mechanism: the seller issues the invoice without VAT and the buyer self-assesses at their local rate. This is straightforwardly beneficial — but it requires the buyer to have a valid, active VAT number at the time the invoice is issued.
Under Council Directive 2018/1910 (Quick Fixes 2020), a valid VAT number is a substantive legal condition — not just an administrative formality — for applying the VAT exemption on intra-EU B2B supplies of goods. For cross-border B2B services, the buyer's VAT number is equally important in practice (it determines whether reverse charge applies), but the substantive-condition framing derives from a different basis under the VAT Directive. Either way, your integration needs to confirm validity at the time of invoicing and retain evidence of that confirmation.
Good practice is to validate at checkout or contract creation, then re-validate periodically for active subscriptions. VAT registrations can lapse.
Step 1: client-side format validation
Before making an API call, check whether the number matches the expected format for its country. This runs locally, costs nothing, and gives the user an immediate error message for obvious typos.
All EU VAT numbers start with the two-letter country prefix. Format rules vary by country:
| Country | Format | Example |
|---|---|---|
| Germany | DE + 9 digits | DE134214316 |
| France | FR + 2 alphanumeric + 9 digits | FR40303265045 |
| Netherlands | NL + 9 digits + B + 2 digits | NL123456789B01 |
| Poland | PL + 10 digits | PL5260001196 |
| Ireland | IE + 7 digits + 1–2 letters | IE6388047V |
| Spain | ES + 1 char + 7 digits + 1 char | ESA12345674 |
| Italy | IT + 11 digits | IT00743110157 |
| Sweden | SE + 12 digits | SE556460819201 |
Here is a minimal client-side check in JavaScript. This runs before you make an API call — it catches clearly malformed input immediately:
// Per-country VAT format patterns (subset — all 27 EU member states)
const VAT_PATTERNS: Record<string, RegExp> = {
AT: /^ATU\d{8}$/,
BE: /^BE[01]\d{9}$/,
DE: /^DE\d{9}$/,
DK: /^DK\d{8}$/,
ES: /^ES[A-Z0-9]\d{7}[A-Z0-9]$/,
FR: /^FR[A-HJ-NP-Z0-9]{2}\d{9}$/,
IE: /^IE\d{7}[A-W][A-IW]?$|^IE\d[A-Z+*]\d{5}[A-W]$/,
IT: /^IT\d{11}$/,
NL: /^NL\d{9}B\d{2}$/,
PL: /^PL\d{10}$/,
SE: /^SE\d{12}$/,
// ... all 27 EU member states
}
function validateVatFormat(vatId: string): { valid: boolean; error?: string } {
// Normalize: trim, uppercase, remove spaces and dashes
const normalized = vatId.trim().toUpperCase().replace(/[\s\-.]/g, '')
if (normalized.length < 4) {
return { valid: false, error: 'VAT number is too short' }
}
const countryCode = normalized.slice(0, 2)
const pattern = VAT_PATTERNS[countryCode]
if (!pattern) {
return { valid: false, error: `Unknown country prefix: ${countryCode}` }
}
if (!pattern.test(normalized)) {
return { valid: false, error: `Invalid format for ${countryCode}. Expected e.g. ${countryCode}123456789` }
}
return { valid: true }
}
// Usage in a checkout form
const input = document.getElementById('vat-number') as HTMLInputElement
const check = validateVatFormat(input.value)
if (!check.valid) {
showFieldError(check.error!) // show inline, do not block form submission yet
}A format check alone is not sufficient for compliance. A correctly formatted number may belong to a company that has since deregistered. The next step is a live lookup.
Step 2: live EU VAT number validation via API
The vatnode API checks the EU VAT registry in real time and returns a structured JSON response — company name, registered address, legal form, industry code, current VAT rates for the buyer's country, and a unique checkId you can store as an audit record.
The endpoint accepts the full VAT number including country prefix. Whitespace, dashes, and mixed case are normalized automatically.
curl https://api.vatnode.dev/v1/vat/IE6388047V \
-H "Authorization: Bearer vat_live_your_key_here"Example response:
{
"valid": true,
"vatId": "IE6388047V",
"countryCode": "IE",
"countryName": "Ireland",
"companyName": "GOOGLE IRELAND LIMITED",
"companyAddress": "3RD FLOOR, 1 GRAND CANAL QUAY, DUBLIN 2",
"companyRegistrationDate": null,
"companyForm": null,
"industryDescription": null,
"registryCode": null,
"registryCodeName": "CRO Number",
"checkId": "01956b4a-1234-7abc-8def-0123456789ab",
"verifiedAt": "2026-04-22T10:31:04.201Z",
"consultationNumber": null,
"countryVat": {
"vatName": "Value Added Tax",
"vatAbbr": "VAT",
"currency": "EUR",
"standardRate": 23,
"reducedRates": [9, 13.5],
"superReducedRate": 4.8,
"parkingRate": 13.5,
"vatNumberFormat": "IE + 7 digits + 1-2 letters",
"countryVatUpdatedAt": "2026-04-22T07:00:00.000Z"
},
"source": "VIES"
}Key fields to act on in your application:
validBoolean — true means the number is currently active in the EU registry. Apply reverse charge only when true.checkIdUnique identifier for this validation event. Store this alongside your invoice record as your audit trail.verifiedAtISO 8601 timestamp of the validation. Required alongside checkId for audit evidence.consultationNumberEU Commission–issued proof of consultation. Present when your own VAT number is configured as a requester. Strongest form of audit evidence.countryVat.standardRateCurrent VAT rate for the buyer's country — useful for displaying the correct rate on your invoice or pre-checkout summary.
interface VatCheckResult {
valid: boolean
vatId: string
countryCode: string
companyName: string | null
checkId: string
verifiedAt: string
consultationNumber: string | null
countryVat: {
standardRate: number
reducedRates: number[]
}
}
interface VatCheckError {
error: {
code: 'INVALID_FORMAT' | 'RATE_LIMITED' | 'VIES_UNAVAILABLE' | 'VIES_ERROR'
message: string
requestId: string
}
}
async function validateBuyerVat(vatId: string): Promise<VatCheckResult> {
const response = await fetch(
`https://api.vatnode.dev/v1/vat/${encodeURIComponent(vatId)}`,
{
headers: {
Authorization: `Bearer ${process.env.VATNODE_API_KEY}`,
},
}
)
const data = (await response.json()) as VatCheckResult | VatCheckError
if (!response.ok) {
const err = data as VatCheckError
throw Object.assign(new Error(err.error.message), { code: err.error.code })
}
return data as VatCheckResult
}
// B2B checkout handler
async function handleB2BCheckout(customerId: string, rawVatId: string) {
try {
const result = await validateBuyerVat(rawVatId)
if (!result.valid) {
// VAT number is known-invalid — do not apply reverse charge
return {
applyReverseCharge: false,
vatCheckId: result.checkId,
verifiedAt: result.verifiedAt,
message: 'VAT number could not be verified. Standard VAT will apply.',
}
}
// Store checkId + verifiedAt in your order/invoice record for audit
await db.orders.update(customerId, {
vatId: result.vatId,
vatCheckId: result.checkId,
vatVerifiedAt: result.verifiedAt,
vatConsultationNumber: result.consultationNumber,
companyName: result.companyName,
applyReverseCharge: true,
})
return {
applyReverseCharge: true,
companyName: result.companyName,
vatCheckId: result.checkId,
verifiedAt: result.verifiedAt,
}
} catch (err: unknown) {
const error = err as { code?: string; message: string }
if (error.code === 'VIES_UNAVAILABLE') {
// Upstream temporarily unavailable — see Section 3 for handling
throw error
}
if (error.code === 'INVALID_FORMAT') {
return {
applyReverseCharge: false,
message: 'Invalid VAT number format. Please check and try again.',
}
}
// Unexpected error — log and surface a generic message
console.error('VAT check failed unexpectedly:', error.message)
throw error
}
}Step 3: handling upstream unavailability without blocking users
The EU VAT registry is a federated system: each member state runs its own national node. Country nodes do occasionally become temporarily unavailable during maintenance windows. When that happens, a naive integration would block checkout or onboarding — an unnecessary and expensive friction point for a customer who is almost certainly legitimate.
The vatnode API includes automatic national registry fallback via local tax authority and company registry APIs for a growing set of EU member states. When a VIES country node is temporarily unavailable, the API attempts the national registry for that country before surfacing an error. For countries without a national fallback adapter, the API returns a VIES_UNAVAILABLE error so your application can handle it explicitly.
Do not block checkout on VIES_UNAVAILABLE.The right pattern is to allow the transaction to proceed, queue the validation for async retry, and set the tax treatment conservatively (charge VAT) until the retry confirms the buyer's status. Most customers in this situation are legitimate EU businesses.
Here is a Python example showing the recommended handling pattern:
import os
import httpx
from dataclasses import dataclass
from typing import Optional
VATNODE_API_KEY = os.environ["VATNODE_API_KEY"]
@dataclass
class VatResult:
valid: bool
vat_id: str
company_name: Optional[str]
check_id: str
verified_at: str
consultation_number: Optional[str]
class ViesUnavailableError(Exception):
"""VIES or the member-state node is temporarily unavailable."""
pass
class InvalidVatFormatError(Exception):
pass
def validate_vat(vat_id: str) -> VatResult:
"""
Validate EU VAT number via vatnode REST API.
Raises ViesUnavailableError if the upstream registry is temporarily down.
Raises InvalidVatFormatError if the VAT number format is invalid.
"""
with httpx.Client(timeout=15.0) as client:
response = client.get(
f"https://api.vatnode.dev/v1/vat/{vat_id}",
headers={"Authorization": f"Bearer {VATNODE_API_KEY}"},
)
if response.status_code == 503:
data = response.json()
if data.get("error", {}).get("code") == "VIES_UNAVAILABLE":
raise ViesUnavailableError(f"VIES unavailable for {vat_id}")
if response.status_code == 400:
data = response.json()
raise InvalidVatFormatError(data.get("error", {}).get("message", "Invalid format"))
response.raise_for_status()
data = response.json()
return VatResult(
valid=data["valid"],
vat_id=data["vatId"],
company_name=data.get("companyName"),
check_id=data["checkId"],
verified_at=data["verifiedAt"],
consultation_number=data.get("consultationNumber"),
)
# In your checkout or onboarding handler:
def handle_b2b_customer(customer_id: str, vat_id: str) -> dict:
try:
result = validate_vat(vat_id)
# Store audit record with the order/invoice
db.orders.update(customer_id, {
"vat_id": result.vat_id,
"vat_check_id": result.check_id,
"vat_verified_at": result.verified_at,
"vat_consultation_number": result.consultation_number,
"company_name": result.company_name,
"apply_reverse_charge": result.valid,
})
return {"apply_reverse_charge": result.valid, "company_name": result.company_name}
except ViesUnavailableError:
# Do not block the transaction — queue for async retry
job_queue.enqueue("retry_vat_check", customer_id=customer_id, vat_id=vat_id)
# Conservative: charge VAT until confirmed
return {"apply_reverse_charge": False, "pending_vat_check": True}
except InvalidVatFormatError as e:
return {"apply_reverse_charge": False, "error": str(e)}Step 4: storing validation records for your audit trail
The validation record is not just useful to have — for EU B2B invoicing it is the evidence that the reverse charge was applied on the basis of a verified VAT status. EU tax authorities may request evidence that a VAT number was verified at the time of invoicing during an audit. The VIES consultation number (consultationNumber) is the EU Commission–issued audit reference — it demonstrates that a live VIES check was performed at a specific timestamp with your VAT identity as the requester.
At minimum, store these fields alongside every invoice you issue with reverse charge applied:
| Field | What it is | Where to store it |
|---|---|---|
checkId | Unique ID for this validation event (UUIDv7) | Invoice record / order metadata |
verifiedAt | Timestamp the validation was performed | Invoice record / order metadata |
consultationNumber | EU Commission consultation reference (when requester VAT configured) | Invoice record — strongest audit evidence |
vatId | Customer's validated VAT number (normalized) | Customer record + invoice |
companyName | Legal name as returned by the registry | Invoice recipient field |
To receive a consultationNumber in every response, configure your own EU VAT number as the requester in the vatnode dashboard (Settings → Requester VAT). The API will then call the EU registry via the checkVatApprox operation, which causes the EU Commission to issue a reference number tied to your VAT identity. No changes to your integration code are required.
Alternatively, pass the requester inline per-request using query parameters:
# Supply your own VAT as requester to receive a consultation number
curl "https://api.vatnode.dev/v1/vat/IE6388047V?requesterCountryCode=DE&requesterVatNumber=134214316" \
-H "Authorization: Bearer vat_live_your_key_here"
# Response includes:
# "consultationNumber": "WAPIAAAAWpgnLGnO" ← EU Commission-issued audit referenceComing from a direct VIES integration? The vatnode API is a REST JSON interface that works with any HTTP client — no protocol-specific libraries needed. See the migration guide for a side-by-side request comparison and what changes in your codebase.
Next steps
- Quickstart guide — make your first API call in under 5 minutes.
- Configure your requester VAT in the dashboard to unlock consultation numbers on every response.
- Set up VAT monitoring subscriptions to be notified when a customer's VAT status changes — available from the Starter plan.
- See the Stripe VAT validation guide for a full checkout integration pattern with webhook handling.
Ready to validate EU VAT numbers in your app?
Free plan includes 100 requests/month — enough to validate the full flow. No credit card required.