A customer has just entered a VAT number at checkout. The invoice is large enough that reverse charge treatment matters, the customer expects VAT to disappear, and your backend has only a few seconds to decide whether that tax treatment is defensible. The frustrating part is that the number can look perfectly plausible while being unregistered, stale, or temporarily impossible to validate.
I've shipped VAT checks for SaaS billing systems, and the production lesson is simple: VIES isn't a normal REST endpoint. It's an official EU service that queries national VAT databases, but national systems can be unavailable and the service can return operational failures that your checkout must handle safely. The reliable design is a four-stage pipeline, format check, local cache, remote check, and audit log.
Two rules should shape the implementation from the start:
- Never trust a validity flag from the browser. The server must perform or retrieve the validation.
- Never make checkout wait indefinitely for VIES. A remote tax lookup needs a timeout and a documented fallback.
Table of Contents
- The B2B VAT Moment You Will Hit in Production
- Format Checks Before You Touch the Network
- VIES vs a Managed VAT API
- Calling a VAT API from Node.js and Python
- Handling VIES Failures With Clean Error Codes
- Wiring VAT Validation Into Stripe Checkout
- The Production VAT Pipeline and Final Checklist
The B2B VAT Moment You Will Hit in Production
Your SaaS has just issued a sizeable invoice to a logistics company in Berlin. At checkout, the buyer selects B2B billing and enters DE 123456789, expecting reverse charge instead of German VAT. Your backend must decide whether that tax treatment is defensible, not merely whether the text resembles a VAT number. The team also needs a clear process for understanding reverse charge VAT obligations.
That decision breaks into four separate facts:
- Is the country prefix and identifier structurally plausible?
- Has this normalized number been checked recently?
- Does Germany's national VAT database confirm it through VIES now?
- Can finance reconstruct the decision after issuing the invoice?
The European Commission describes VIES as the official VAT-number validation service for cross-border EU transactions. It covers all 27 EU member states and Northern Ireland and returns a binary result, valid or invalid, by querying the relevant national database instead of maintaining a central copy of every registration.
That makes VIES the right source for a live check, but it does not behave like a predictable REST endpoint. A national registry may be unavailable, a request may time out, or the upstream service may reject calls because too many requests are already running. Your application must distinguish each failure from a confirmed invalid number.
The four stages
Format check runs locally and gives the user immediate feedback. It catches misplaced prefixes, unsupported characters, and country-specific length errors before consuming a network request.
Local cache serves numbers checked recently. It reduces latency and repeated calls for the same customer or supplier. Set an expiry policy because registration status can change, and keep the cached result separate from a fresh confirmation.
Remote check asks VIES, or a managed provider that calls VIES, for the current result. Only this stage can confirm registration status. A format pass never justifies reverse charge treatment.
Audit log records what happened, when it happened, and which request produced the result. Store the normalized VAT ID, response status, timestamp, provider response type, and consultation number where available. Current guidance on VAT validation changes and evidence also supports retaining evidence of the validation process.
The output should stay predictable: a status, a tax decision, and an audit row. If VIES is unavailable, classify the transaction as pending or allow-and-flag according to your risk policy. An outage is not evidence that the customer's VAT number is invalid.
Format Checks Before You Touch the Network
A format validator checks structure, not registration. It answers whether the submitted value matches the identifier pattern for the supplied country. EU VAT identifiers use country-specific rules, including different lengths, letters, and checksum requirements. Use this EU VAT-number format reference for the supported patterns, alongside the EU VAT-number format reference for a broader overview.
Run this stage before any API call. The browser can return immediate correction feedback, malformed input will not consume upstream capacity, and the remote layer receives one predictable value rather than raw user input.
Normalize first
Accept common entries such as DE 123 456 789, de.123456789, or DE123456789, then normalize them once:
export function normalizeVatInput(input) {
const compact = String(input ?? "")
.trim()
.replace(/[\s.]/g, "")
.toUpperCase();
const match = compact.match(/^([A-Z]{2})([A-Z0-9]+)$/);
if (!match) {
return { valid: false, country: null, normalized: compact, reason: "invalid_prefix" };
}
return {
valid: true,
country: match[1],
normalized: compact
};
}
Extract the country code before checking the identifier. A single global regex will produce false results because each country has its own format. Keep the rules explicit and testable:
const rules = {
DE: /^\d{9}$/,
FR: /^[A-Z0-9]{2}\d{9}$/,
GB: /^(\d{9}|\d{12}|GD\d{3}|HA\d{3})$/,
NL: /^\d{9}B\d{2}$/,
IE: /^[0-9A-Z]{7,8}$/,
IT: /^\d{11}$/,
ES: /^[A-Z0-9]\d{7}[A-Z0-9]$/,
PL: /^\d{10}$/
};
The French rule shows the limit of regex validation. It checks the expected character shape, while a production implementation should also apply the country's checksum algorithm. Treat a checksum failure as local invalid input, not as proof from the national registry.
Return a stable contract
export function validateVatFormat(input) {
const base = normalizeVatInput(input);
if (!base.valid) return base;
const identifier = base.normalized.slice(2);
const rule = rules[base.country];
if (!rule) {
return {
valid: false,
country: base.country,
normalized: base.normalized,
reason: "unsupported_country"
};
}
if (!rule.test(identifier)) {
return {
valid: false,
country: base.country,
normalized: base.normalized,
reason: "invalid_format"
};
}
return {
valid: true,
country: base.country,
normalized: base.normalized
};
}
A passing result means structurally plausible, not legally valid. The remote service still must confirm that the identifier exists and supports the intended cross-border tax treatment.
In production, cover every supported member-state format, add tests with valid and invalid fixtures, and keep this layer separate from registry validation. That boundary lets the cache, remote check, and audit log record a provider outage without mislabeling it as a customer typo.
VIES vs a Managed VAT API
A checkout can pass every local format check, then stall because VIES is unavailable. That production failure presents the key choice: decide whether your team owns the remote integration and its downtime, or pays a provider to absorb part of that work.
Direct VIES is free and authoritative for confirming VAT-number validity in intra-Community goods and services transactions under Council Regulation (EC) No. 904/2010. It is not a normal REST dependency. VIES uses SOAP, may return plain-text faults, depends on national databases, and offers no checkout-oriented uptime contract. Recurring downtime and country-level failures are documented in VIES limitations and country-specific failure guidance. The reported figures there are from that guidance, not from the European Commission.
A managed API charges per lookup or under a plan. In return, it may handle SOAP translation, retries, caching, monitoring, normalized JSON, and a stable error vocabulary. You still need to inspect its terms, retention policy, rate limits, and evidence fields. Outsourcing the transport layer does not outsource your tax decision.
Honest trade-offs
| Dimension | VIES, direct | Managed VAT API |
|---|---|---|
| Cost | No provider lookup fee | Paid per check or by plan |
| Response format | SOAP and provider-specific faults | Usually normalized JSON |
| Uptime | No checkout-oriented SLA | May include an uptime commitment |
| Caching | You build and operate it | Usually included or configurable |
| Error handling | You classify faults yourself | Provider may return stable error codes |
| Rate limits | Operational limits can be opaque | Limits and quotas are documented by provider |
| Audit trail | You store the response and reference | Some providers expose evidence fields or logs |
Choose based on failure ownership
Direct VIES suits a prototype, a low-volume B2B invoicing tool, or a team prepared to maintain SOAP adapters. The adapter still needs bounded retries, a circuit breaker, structured logs, and a policy for an unavailable result. Cache successful responses with an explicit freshness rule, and never convert a timeout into invalid.
A managed API fits a customer-facing checkout where a remote failure must not become a payment failure. TaxID is one example. Its REST endpoint accepts tax identifiers across supported countries and returns normalized validation status, company name, and address. Its platform also provides format checks, Redis-backed caching, and standardized failure codes.
The practical rule is simple: if a VIES outage can block revenue, do not make raw VIES your only runtime dependency. Put a narrow adapter between checkout and the provider, retain the raw response or reference in the audit log, and let the next stage decide whether an unavailable check permits review, deferred invoicing, or checkout continuation.
Calling a VAT API from Node.js and Python
A checkout request should not know whether verification came from VIES, a managed provider, or a local cache. Keep that provider boundary narrow, then return one contract to billing, checkout, and audit logging.
A useful result carries the normalized identifier, status, company details, and request ID:
{
"status": "valid",
"vatNumber": "DE123456789",
"companyName": "Example GmbH",
"address": "Berlin, Germany",
"requestId": "req_abc123"
}
The four-stage pipeline is format check, cache lookup, remote verification, and audit write. The clients below cover the remote stage. A failed remote call returns unavailable, never invalid, so checkout can apply its own fallback policy.
Node.js client
Use native fetch, an AbortController, and a hard four-second deadline. The request ID follows the lookup into provider logs and your audit record.
export async function verifyVat(vatNumber, requestId) {
const normalized = vatNumber.replace(/[\s.]/g, "").toUpperCase();
const country = normalized.slice(0, 2);
const number = normalized.slice(2);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 4000);
try {
const response = await fetch(process.env.VAT_API_URL, {
method: "POST",
headers: {
"content-type": "application/json",
"x-request-id": requestId
},
body: JSON.stringify({ country, number }),
signal: controller.signal
});
const body = await response.json();
if (!response.ok) {
return {
status: "unavailable",
vatNumber: normalized,
companyName: null,
address: null,
requestId,
error: body.code || "VAT_PROVIDER_ERROR"
};
}
return {
status: body.valid ? "valid" : "invalid",
vatNumber: normalized,
companyName: body.companyName ?? null,
address: body.address ?? null,
requestId
};
} catch (error) {
return {
status: "unavailable",
vatNumber: normalized,
companyName: null,
address: null,
requestId,
error: error.name === "AbortError"
? "VAT_PROVIDER_TIMEOUT"
: "VAT_PROVIDER_ERROR"
};
} finally {
clearTimeout(timer);
}
}
Validate the body before calling the client. The handler then maps the result into a response suitable for the next application layer:
app.post("/vat/verify", async (req, res) => {
const requestId = crypto.randomUUID();
const result = await verifyVat(req.body.vatNumber, requestId);
const status = result.status === "invalid" ? 422 :
result.status === "unavailable" ? 503 : 200;
res.status(status).json(result);
});
Python client
A shared Session reuses connections. The dataclass keeps Python consumers aligned with the Node.js contract.
from dataclasses import dataclass
import requests
@dataclass
class VatResult:
status: str
vat_number: str
company_name: str | None
address: str | None
request_id: str
error: str | None = None
session = requests.Session()
def verify_vat(vat_number: str, request_id: str) -> VatResult:
normalized = "".join(vat_number.split()).replace(".", "").upper()
try:
response = session.post(
"https://provider.example/v1/vat/verify",
json={
"country": normalized[:2],
"number": normalized[2:]
},
headers={"X-Request-ID": request_id},
timeout=4
)
body = response.json()
if not response.ok:
return VatResult(
"unavailable", normalized, None, None, request_id,
body.get("code", "VAT_PROVIDER_ERROR")
)
return VatResult(
"valid" if body.get("valid") else "invalid",
normalized,
body.get("companyName"),
body.get("address"),
request_id
)
except requests.Timeout:
return VatResult(
"unavailable", normalized, None, None, request_id,
"VAT_PROVIDER_TIMEOUT"
)
A minimal FastAPI route can return the dataclass directly:
from fastapi import FastAPI
import uuid
app = FastAPI()
@app.post("/vat/verify")
def verify_endpoint(payload: dict):
request_id = str(uuid.uuid4())
return verify_vat(payload["vatNumber"], request_id)
Retry only idempotent lookups, reuse the same request ID, and keep retry time outside the checkout deadline. Store the result or failure code in the audit log after the cache and remote stages. The Node.js VAT API quickstart shows how to build the provider adapter without exposing SOAP details to application code.
Handling VIES Failures With Clean Error Codes
A VIES outage should not become a false negative or a checkout crash. Convert provider responses into a small internal status set at the integration boundary. Checkout, billing, support, and the audit log can then handle the same stable values without parsing strings such as "MS_MAX_CONCURRENT_REQ".
Keep invalid separate from unavailable. VIES returns a negative result when a completed lookup says the number is not valid. A timeout, SOAP fault, or member-state outage means the lookup did not complete. That distinction must remain intact through the tax and invoice decision.
A stable error vocabulary
| Raw failure | Error code | User message | Retry policy | HTTP status |
|---|---|---|---|---|
MS_MAX_CONCURRENT_REQ |
VAT_PROVIDER_BUSY |
“We couldn't verify this number right now. Please try again.” | Exponential backoff, bounded | 503 |
| National database unavailable | VAT_COUNTRY_DOWN |
“This country's registry is temporarily unavailable.” | Retry later | 503 |
| SOAP fault | VAT_PROVIDER_ERROR |
“The VAT service returned an unexpected error.” | Retry with circuit breaker | 502 |
| Client timeout | VAT_PROVIDER_TIMEOUT |
“Verification timed out. We've saved the request for review.” | Retry asynchronously | 504 |
| Malformed value after local checks | VAT_INVALID_INPUT |
“Enter a valid VAT number for the selected country.” | Do not retry | 422 |
export function mapVatFailure(raw = "") {
const message = String(raw).toUpperCase();
if (message.includes("MS_MAX_CONCURRENT_REQ")) {
return "VAT_PROVIDER_BUSY";
}
if (message.includes("SERVICE UNAVAILABLE") ||
message.includes("MEMBER STATE")) {
return "VAT_COUNTRY_DOWN";
}
if (message.includes("TIMEOUT") ||
message.includes("ABORT")) {
return "VAT_PROVIDER_TIMEOUT";
}
if (message.includes("INVALID")) {
return "VAT_INVALID_INPUT";
}
return "VAT_PROVIDER_ERROR";
}
Retry without hanging checkout
Retry only transient failures, and keep the retry budget outside the checkout deadline. A malformed identifier cannot become valid through another request, so stop immediately for VAT_INVALID_INPUT. For busy-provider, country-availability, and transport failures, use a bounded policy with jitter. Reuse the same request ID across attempts so logs and audit records describe one verification attempt.
An unavailable result needs an explicit business decision:
- Block tax exemption: keep VAT applied and ask the customer to retry or contact billing.
- Allow and flag: complete the transaction, mark the tax decision as pending, and revalidate before final invoicing or through a controlled follow-up workflow.
The choice depends on tax counsel, product risk, and invoice timing. An outage must never be converted into valid.
Write the normalized code to the audit log after the cache and remote stages. Store the raw provider response separately, with the request ID and relevant timestamps, so engineers can diagnose upstream changes without making application code depend on provider wording. Avoid placing unnecessary personal or payment data in that record.
Wiring VAT Validation Into Stripe Checkout
A customer can reach the payment page with a VAT ID that is mistyped, stale, or impossible to verify because VIES is unavailable. Put the decision before creating a Stripe Checkout Session. The browser may collect the identifier, but your server must own the format check, cache lookup, remote check, and audit record.
A production flow is:
- The buyer selects B2B billing and submits a VAT ID.
- The server normalizes and format-checks it.
- The server uses a recent cache result or calls the remote validator.
- The server writes the normalized outcome to the audit log.
- Only
validenables the reverse-charge path. - Stripe receives the verified identifier and internal request ID.
app.post("/billing/create-checkout-session", async (req, res) => {
const requestId = crypto.randomUUID();
const vat = await pipeline.verify(req.body.vatNumber, requestId);
if (vat.status === "invalid") {
return res.status(422).json({
code: "VAT_INVALID_INPUT",
message: "Enter a valid VAT number."
});
}
if (vat.status !== "valid") {
return res.status(503).json({
code: vat.error || "VAT_PROVIDER_UNAVAILABLE",
message: "VAT verification is temporarily unavailable."
});
}
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: buildLineItems(req.body.plan),
automatic_tax: { enabled: true },
customer_creation: "always",
metadata: {
vatNumber: vat.vatNumber,
vatValidationRequestId: requestId,
vatValidationStatus: vat.status
},
customer_update: {
address: "auto",
name: "auto"
}
});
res.json({ id: session.id });
});
Stripe tax behavior depends on account configuration and API version. Keep the validated number on the customer and session so the invoice service can reconcile it with the audit row. automatic_tax does not replace your evidence record, and a browser-provided reverseCharge: true flag must not control tax treatment.
The webhook should connect the completed session and invoice to the corresponding audit entry. For subscriptions, define when to revalidate before renewal. A number valid during onboarding can become stale, so long-lived accounts may need a new check under your compliance policy.
Billing lifecycle actions remain separate. A team handling a request to cancel a subscription from the Stripe dashboard should change subscription state without deleting VAT audit history. Cancellation affects billing data, not the evidence supporting invoices already issued.
The Production VAT Pipeline and Final Checklist
VAT verification in production is a pipeline, not a single HTTP request. VIES can be unavailable, slow, or return provider-specific faults, so checkout needs clear stages and states.

- Format check: Normalize the country code and identifier, then apply country-specific rules before using the network.
- Local cache lookup: Return a recent result when policy allows, while preserving its validation timestamp.
- Remote VIES call: Request current registry confirmation directly or through a managed provider.
- Audit log entry: Store the status, timestamp, request ID, response type, and consultation number when available.
Keep each stage independent. The format layer rejects obvious input errors, the cache reduces latency and upstream load, the remote check supplies current confirmation, and the audit row lets finance reconstruct why an invoice used reverse charge. Add fallback handling and periodic revalidation because customer data becomes stale and national services can be intermittently unavailable.
Production checklist
- Country coverage: Test format rules for every EU member state you support.
- Cache policy: Set expiry according to compliance risk and revalidation policy. Never cache indefinitely.
- Fallback behavior: Return a clear pending or unavailable state when VIES cannot respond.
- Error contracts: Map provider failures to machine-readable codes.
- Audit identity: Key each row by request ID and link it to the customer, invoice, and Stripe event.
- Retry controls: Document retryable errors, attempt limits, and circuit-opening conditions.
- Data hygiene: Revalidate existing customer and supplier records, not only new accounts.
- Operational review: Exercise outage paths, inspect audit rows, and give support a customer-facing message.
Use this production-readiness checklist to turn the items into tickets and release checks.
The implementation is ready when checkout stays responsive during an upstream outage, finance can reconstruct each tax decision, and application code never has to parse SOAP fault text.
TaxID provides a REST endpoint for validating VAT and company identification numbers, including EU checks through VIES, with normalized statuses, available company details, caching, and machine-readable failures. Visit TaxID to add VAT verification to Node.js, Python, Stripe, or custom checkout flows without implementing the VIES reliability layer yourself.