A buyer reaches your B2B SaaS checkout, enters a German VAT number, and expects the total to update immediately. Your Stripe flow sees a value that looks plausible, applies reverse charge, and creates the invoice. Later, finance discovers that the number was never checked against the EU registry, so the invoice is wrong and someone has to repair the transaction manually.
That failure rarely starts with tax theory. It starts with an engineering assumption: a syntactically valid VAT number must be a registered VAT number. It isn't. If you're building a reliable “check VAT EU” flow, you need to treat validation as a distributed-systems problem involving your checkout, billing platform, cache, retry policy, and a national registry that doesn't provide your application with a guaranteed uptime contract.
Table of Contents
- Why Checking an EU VAT Number Is Harder Than It Looks
- Validating VAT Format Before You Call VIES
- Inside the VIES SOAP Contract
- Comparing DIY VIES, a Managed API, and Regex-Only
- Sample Code for Node.js and Python
- Error Handling Patterns That Prevent Lost Sales
- Production Checklist for Checkout and Billing Flows
Why Checking an EU VAT Number Is Harder Than It Looks
A customer enters a VAT number, your Stripe checkout accepts the format, and the invoice applies reverse charge. Hours later, the registry lookup fails or shows the registration was not valid. The tax decision now needs manual correction, and the original checkout flow offers little evidence for an audit.
The European Commission describes VIES as the official service for checking whether another business is VAT registered for intra-EU transactions. Its response is a point-in-time result. It confirms the status returned for that query, not whether the number was valid previously or had taxable status on an earlier date. The Commission recommends retaining validation records, so a production integration needs request details, responses, timestamps, and the decision made from them. The European Commission's VIES guidance explains that distinction.
The engineering contract has several weak points. Your application sends a country and identifier to a service that depends on the relevant member-state register. That register can lag behind a recent business change, reject traffic, or stop responding. Independent monitoring reported 3 to 8 incidents in a 30-day window in which at least one member state was unavailable. It also reported Belgium and Germany unavailable about 32% of the time during one three-day period, with Romania and Bulgaria unavailable roughly 9.8% and 9.5% of that period. The observations are documented in industry analysis of VIES reliability.
The assumptions that break checkout
Three failure modes deserve separate handling:
- Throttling: A member-state endpoint can reject a request because it is limiting concurrency. The same report found
MS_MAX_CONCURRENT_REQin 95% of the German VAT verification failures it observed. That error indicates an operational limit, not necessarily incorrect customer data. - Country-specific downtime: One national endpoint can fail while other countries continue responding. A single global health check will miss that distinction.
- Queued batch work: Supplier imports and invoice backfills can consume the same capacity as checkout lookups. An unbounded batch queue can leave a synchronous purchase waiting for minutes.
Registry lag creates a separate trap. A successful response can still reflect the register's current state rather than the state on the invoice date. Store the response and your decision instead of relying on a later recheck.
Brexit changed the operational framework. On 1 January 2021, the old UK VoW validation service for Great Britain ceased, while a new service was introduced for businesses operating under the Protocol on Ireland and Northern Ireland.
For wider company obligations, EU Inc compliance explained provides business context. Keep that separate from implementation. The checkout must distinguish invalid input, invalid registration, and an unavailable authority.
Validating VAT Format Before You Call VIES
The first check should run locally. Normalize harmless presentation differences, extract the country code deliberately, and reject values that cannot match the issuing country's structure before making a network request. This is fast, predictable, and protects the remote service from obvious mistakes.
A prefix alone isn't enough. Germany uses a country code followed by 9 digits, while France uses an 11-character identifier with a checksum component. The Netherlands uses a structured identifier with the BTW suffix, and Ireland accepts combinations of digits and letters. A user can also paste a lowercased prefix, a trailing period from an email signature, or a number without its country code. Those aren't registry questions. They're input-handling problems.
A practical pre-flight sequence
- Trim surrounding whitespace.
- Convert the country prefix to uppercase.
- Remove only formatting characters your product explicitly allows.
- Keep the country code in a separate field.
- Apply the country-specific structural check.
- Send only the normalized national number to the remote validator.
Don't turn the format layer into a pretend tax authority. It can tell you that DE123456789 has the expected German shape. It can't tell you whether the number is currently registered. The VAT number format glossary is a useful reference when you build the country rules.
EU VAT number format by member state
| Member State | Country Code | Regex Pattern | Checksum |
|---|---|---|---|
| Austria | AT | ATU[0-9]{8} |
Austrian checksum |
| Belgium | BE | BE[0-9]{10} |
Belgian checksum |
| Bulgaria | BG | BG[0-9]{9,10} |
National validation |
| Croatia | HR | HR[0-9]{11} |
Croatian checksum |
| Cyprus | CY | CY[0-9]{8}[A-Z] |
National validation |
| Czechia | CZ | CZ[0-9]{8,10} |
National validation |
| Denmark | DK | DK[0-9]{8} |
Danish checksum |
| Estonia | EE | EE[0-9]{9} |
Estonian checksum |
| Finland | FI | FI[0-9]{8} |
Finnish checksum |
| France | FR | FR[A-Z0-9]{2}[0-9]{9} |
French key |
| Germany | DE | DE[0-9]{9} |
German checksum |
| Greece | EL | EL[0-9]{9} |
Greek checksum |
| Hungary | HU | HU[0-9]{8} |
Hungarian checksum |
| Ireland | IE | IE[0-9A-Z]{8,9} |
Irish checksum |
| Italy | IT | IT[0-9]{11} |
Italian checksum |
| Latvia | LV | LV[0-9]{11} |
Latvian checksum |
| Lithuania | LT | LT[0-9]{9,12} |
Lithuanian checksum |
| Luxembourg | LU | LU[0-9]{8} |
Luxembourg checksum |
| Malta | MT | MT[0-9]{8} |
Maltese checksum |
| Netherlands | NL | NL[0-9]{9}B[0-9]{2} |
Dutch checksum |
| Poland | PL | PL[0-9]{10} |
Polish checksum |
| Portugal | PT | PT[0-9]{9} |
Portuguese checksum |
| Romania | RO | RO[0-9]{2,10} |
National validation |
| Slovakia | SK | SK[0-9]{10} |
Slovak checksum |
| Slovenia | SI | SI[0-9]{8} |
Slovenian checksum |
| Spain | ES | ES[0-9A-Z][0-9]{7}[0-9A-Z] |
Spanish checksum |
| Sweden | SE | SE[0-9]{12} |
Swedish checksum |
The exact checksum implementation deserves unit tests per country, not one oversized regular expression. A regex-only solution is useful as a pre-filter, but it must never be the final basis for reverse-charge treatment.
Inside the VIES SOAP Contract
A production VAT check begins with a SOAP contract, not a boolean endpoint. Your client calls the European Commission's VIES service and sends an XML request to the checkVatApprox operation, commonly through the WSDL published for that service. The payload contains countryCode and vatNumber. A response may include valid, name, address, requestIdentifier, and requestDate.
A simplified request looks like this:
<checkVatApprox xmlns="urn:ec.europa.eu:taxud:vies:services:checkVat:types">
<countryCode>DE</countryCode>
<vatNumber>123456789</vatNumber>
</checkVatApprox>
Treat the result as a structured service response. It may contain incomplete company details, identifiers for tracing, or faults that have nothing to do with the VAT number's registration status. SOAP headers, XML namespaces, SOAPAction behavior, historical mutual-TLS requirements, intermittent server errors, and country-level throttling all affect the integration. VIES supplies no idempotency key, so an impatient checkout retry can create duplicate upstream work. Registry lag creates another trap: a newly registered number can remain unavailable to VIES even when the customer's details are correct.

Put a boundary around SOAP
A thin wrapper can expose a stable route:
GET /validate?country=DE&vat=123456789
It can return a consistent JSON shape:
{
"valid": true,
"name": "Example GmbH",
"address": "Example address",
"source": "vies",
"cached_at": "2026-09-13T10:00:00Z"
}
That boundary centralizes authentication, error classification, retries, caching, correlation IDs, and metrics. Stripe handlers, invoice workers, and support tools then consume one contract instead of parsing SOAP independently.
For implementation details, see VIES VAT number validation with the European Commission. Keep raw SOAP outside the production application boundary unless your team has a clear reason to own its protocol and failure handling.
Comparing DIY VIES, a Managed API, and Regex-Only
The right choice depends on what must happen when the registry is slow or unavailable. A marketplace onboarding flow can tolerate asynchronous review. A Stripe checkout has a much tighter user-experience budget. An accounting import may prioritize traceability over immediate response time.
| Approach | Latency (p50) | Reliability vs VIES outage | Dev effort | Best fit |
|---|---|---|---|---|
| DIY VIES | Variable, dependent on VIES | Your team owns retries, caching, and outage classification | High | Teams with strict infrastructure or vendor constraints |
| Managed API | Adds a network hop, usually predictable | Provider can absorb upstream failures and expose a stable contract | Low to medium | SaaS billing, checkout, invoicing |
| Regex-only | Synchronous and local | Unaffected by VIES, but cannot establish registration | Low | Pre-flight validation only |
DIY VIES
Direct integration gives you control over the request path, stored payload, retry policy, and deployment model. It also leaves you responsible for SOAP parsing, malformed responses, national endpoint behavior, throttling, and keeping checkout logic from confusing service failure with an invalid VAT number.
This path makes sense when a compliance or procurement policy prohibits an external validation vendor, or when your platform already operates a mature integration gateway. It doesn't make sense merely because the first SOAP request appears easy. The first successful request is the least interesting part of the system.
Managed validation
A managed API such as TaxID-style infrastructure absorbs the protocol boundary and returns a normalized JSON response. You trade some control and add provider dependency for less maintenance, simpler observability, and a consistent error model. That trade-off is similar to other API design decisions, so an API integration requirements walkthrough can help you document authentication, timeout, schema, and ownership requirements before selecting a provider.
For Stripe webhooks, this is usually the cleanest arrangement. Store the validation result and its timestamp, then let invoice generation consume a durable record instead of calling VIES in the critical webhook path.
Regex-only
Regex is not an alternative to authoritative validation. It answers, “Does this value resemble a number issued by this country?” VIES answers, “Does the relevant registry currently recognize this number for intra-EU validation?” Those questions overlap, but they aren't interchangeable.
Use regex at the edge, then use an authoritative lookup where the transaction requires registration evidence. For a high-traffic checkout with a strict response budget, return a quick format result first, load a cached authoritative result when available, and send uncertain cases into a review or delayed-confirmation path rather than rejecting a legitimate buyer.
Sample Code for Node.js and Python
The cleanest application interface accepts the country separately from the national VAT number. That matters for identifiers such as Germany's ELxxxxx ambiguity, and it prevents a parser from routing a value to the wrong national endpoint.
The following examples assume a TaxID-style REST contract. Replace the endpoint and credential handling with the provider you select. The important application pattern is the same: run local validation first, enforce a timeout, normalize failures, and retain the request identifier for audit logs.
Node.js with native fetch
const VAT_API_URL = "https://api.example.com/v1/validate";
const API_KEY = process.env.VAT_API_KEY;
function looksLikeVat(countryCode, vatNumber) {
const rules = {
DE: /^[0-9]{9}$/,
FR: /^[A-Z0-9]{2}[0-9]{9}$/,
NL: /^[0-9]{9}B[0-9]{2}$/
};
return Boolean(rules[countryCode]?.test(vatNumber));
}
async function validateVat(countryCode, vatNumber) {
const country = countryCode.trim().toUpperCase();
const vat = vatNumber.trim().replace(/\s+/g, "").toUpperCase();
if (!looksLikeVat(country, vat)) {
return { ok: false, code: "vat_format_invalid" };
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
// Check your cache before this request in production.
const response = await fetch(VAT_API_URL, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ countryCode: country, vatNumber: vat }),
signal: controller.signal
});
const body = await response.json();
if (!response.ok) {
return { ok: false, code: body.code || "vat_service_error" };
}
// Store the response and timestamp for audit purposes.
// Treat companyName and address as optional registry fields.
return {
ok: true,
valid: body.valid === true,
companyName: body.companyName ?? null,
countryCode: body.countryCode ?? country,
requestId: body.requestIdentifier ?? null
};
} catch (error) {
return {
ok: false,
code: error.name === "AbortError"
? "vat_timeout"
: "vat_service_unavailable"
};
} finally {
clearTimeout(timeout);
}
}
This pattern keeps the country field explicit and gives the caller a machine-readable result. It also avoids treating a missing company name as an invalid registration, because registries can return partial data.
For a broader integration example, validate a European VAT number shows the same separation between input preparation and remote validation.
Python with requests and a dataclass
from dataclasses import dataclass
import re
import requests
VAT_API_URL = "https://api.example.com/v1/validate"
@dataclass
class VatResult:
valid: bool | None
company_name: str | None
country_code: str
request_id: str | None
error_code: str | None = None
def looks_like_vat(country_code: str, vat_number: str) -> bool:
patterns = {
"DE": r"^[0-9]{9}$",
"FR": r"^[A-Z0-9]{2}[0-9]{9}$",
"NL": r"^[0-9]{9}B[0-9]{2}$",
}
pattern = patterns.get(country_code)
return bool(pattern and re.fullmatch(pattern, vat_number))
def validate_vat(country_code: str, vat_number: str) -> VatResult:
country = country_code.strip().upper()
vat = re.sub(r"\s+", "", vat_number).upper()
if not looks_like_vat(country, vat):
return VatResult(None, None, country, None, "vat_format_invalid")
try:
# Read from cache first in production.
response = requests.post(
VAT_API_URL,
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"countryCode": country, "vatNumber": vat},
timeout=(2, 5),
)
body = response.json()
if not response.ok:
return VatResult(
None, None, country, body.get("requestIdentifier"),
body.get("code", "vat_service_error")
)
# Persist body and timestamp alongside the invoice or customer record.
return VatResult(
valid=body.get("valid"),
company_name=body.get("companyName"),
country_code=body.get("countryCode", country),
request_id=body.get("requestIdentifier"),
)
except requests.Timeout:
return VatResult(None, None, country, None, "vat_timeout")
except requests.RequestException:
return VatResult(None, None, country, None, "vat_service_unavailable")
In both services, valid: false means the authority answered with an invalid result. valid: null or a normalized service error means your application couldn't obtain a trustworthy answer. Keep those states separate.
Error Handling Patterns That Prevent Lost Sales
A checkout shouldn't decline a card because a public registry is having a bad morning. The application needs to distinguish customer-correctable input from an unavailable dependency and from an internal integration defect.

Bad input is a user-flow problem
Malformed values and country mismatches should produce a synchronous field-level error. Tell the buyer whether the country is missing, the structure is wrong, or the number contains unsupported characters. Don't send a malformed value to VIES and then display “invalid VAT number” when the buyer only pasted punctuation.
Input rule: Reject what your code can prove is malformed. Don't reject what an unavailable registry merely failed to confirm.
Outage means inconclusive
A SOAP fault, an empty validity field, or a member-state timeout isn't the same as an authoritative invalid result. Mark the attempt as inconclusive, check for a previously stored result, and offer a manual-review path for a B2B order. If your tax policy permits it, payment can proceed while the billing system holds the reverse-charge decision for confirmation.
VIES availability is uneven by country. As the reliability analysis linked earlier shows, a country-specific failure can affect valid transactions while other member states answer normally.
Network failures need bounded recovery
TLS resets, connection timeouts, and rate-limit responses should use exponential backoff with jitter, a hard cap of three attempts, and a circuit breaker after consecutive failures. The attempt count is an implementation policy, not a claim about VIES behavior. The circuit breaker protects both your checkout and the upstream service.
Log the country, normalized VAT number in a protected form, response classification, request identifier, elapsed time, and retry count. Never expose raw SOAP faults to the customer. Return a stable application code such as vat_invalid, vat_timeout, or service_unavailable, and make sure a VAT failure can't turn into a payment failure.
Production Checklist for Checkout and Billing Flows
A production check is a reliability workflow, not just a successful VIES response. The input, validation service, cache, and invoice lifecycle must agree. A lookup that succeeds in the browser but never reaches the final Stripe invoice provides weak audit evidence.

Use this checklist when wiring the flow:
- Cache successful lookups: Set a business-appropriate cache lifetime, such as 7 to 30 days, while retaining the raw response and validation timestamp for audit retention of at least 6 years. Those retention periods are operational choices for your system. The Commission requires businesses to keep validation records, so align the final policy with your tax adviser and applicable obligations.
- Validate before the network call: Require a separate two-letter country field, normalize the identifier, and apply the country rule before contacting VIES or a wrapper. This pre-flight catches malformed input without turning an unavailable registry into an invalid result.
- Control remote latency: Set a firm timeout and bounded retries. Keep batch imports from consuming the same concurrency budget as interactive checkout.
- Persist evidence: Store the country, national number, result status, response metadata, timestamp, and request identifier. Encrypt or restrict access to customer data according to your security model.
- Fail softly: If the authority is unavailable, show a useful message and offer manual review. Do not apply reverse charge based only on a stale or format-only result.
- Coordinate Stripe events: Validate before
invoice.createdfinalizes the invoice, and makecustomer.updatedchanges idempotent. If the VAT number changes after payment, create a clear state transition instead of overwriting the original evidence.
The registry-lag trap needs its own alert. In 2025, independent reporting described a Czech register issue where registrations from 1 January 2025 onward were not visible in VIES, while older registrations still appeared. Users were directed to the domestic register for verification. Review the reported Czech VIES registration visibility issue before implementing a binary “VIES says no, therefore the company isn't real” rule.
Treat disagreement with a national register as an escalation state. Record the VIES response, preserve the query time, and route the order to a human or documented secondary verification process. This keeps a stale result from deciding the customer outcome.
TaxID provides a single REST endpoint for VAT and company identification validation, including VIES-backed checks across the EU, normalized JSON responses, format checks, caching, and machine-readable service errors. If you're wiring VAT validation into Stripe, a SaaS billing system, or a custom checkout, visit TaxID to review the API and compare it with your reliability requirements.