You launched your product in Europe and suddenly discovered that VAT is not just a field in a form. There is a SOAP API from the early 2000s that your checkout depends on. It goes down without warning. It returns "INVALID" for valid numbers during EU member state outages. Greece uses the prefix EL, not GR. And if you are building B2B software, the entire tax logic flips depending on whether the buyer has a valid VAT number.
I have dealt with this across several production systems — vatnode.dev, a SaaS specifically for EU VAT validation, and pi-pi.ee, a B2B e-commerce platform covering 32 EU markets. This guide covers what I actually learned building them, not what the official documentation says.
How VIES Works (And Why It Is Unreliable)
VIES — the EU VAT Information Exchange System — is the official European Commission service for validating VAT numbers. It connects to tax authority databases in each EU member state and tells you whether a given VAT number is currently registered for intra-EU trade.
The API is SOAP over HTTP. In 2026. There is a REST wrapper available (https://ec.europa.eu/taxation_customs/vies/rest-api), but it is still backed by the same infrastructure.
Here is the core problem: VIES is a federation of 27 national systems. When the Finnish tax authority (Vero) has a maintenance window, VIES returns an error for all Finnish VAT numbers. When the German Bundeszentralamt für Steuern has a timeout, your checkout silently fails for German B2B customers. The European Commission publishes availability statistics — some member states have uptime well below 99%.
The naive implementation looks like this:
// naive-vies.ts — do not use in production
const response = await fetch(
`https://ec.europa.eu/taxation_customs/vies/rest-api/ms/${countryCode}/vat/${vatNumber}`
);
const data = await response.json();
return data.valid; // will be false during downtime, not an error
The critical bug: when VIES is down for a member state, it does not return an HTTP error. It returns { "valid": false }. Your code sees a valid B2B customer as invalid, blocks their checkout, and you never know it happened.
A Production-Grade VIES Wrapper
Here is the wrapper I built for vatnode, with proper error handling and distinction between "definitely invalid" and "could not verify":
// lib/vies.ts
import { z } from "zod";
const VIES_REST_BASE = "https://ec.europa.eu/taxation_customs/vies/rest-api";
// Country codes that VIES uses — note EL for Greece, not GR
const VIES_COUNTRY_CODES = new Set([
"AT",
"BE",
"BG",
"CY",
"CZ",
"DE",
"DK",
"EE",
"EL",
"ES",
"FI",
"FR",
"HR",
"HU",
"IE",
"IT",
"LT",
"LU",
"LV",
"MT",
"NL",
"PL",
"PT",
"RO",
"SE",
"SI",
"SK",
]);
const ViesResponseSchema = z.object({
isValid: z.boolean(),
requestDate: z.string(),
userError: z.string().optional(),
name: z.string().optional(),
address: z.string().optional(),
requestIdentifier: z.string().optional(),
});
export type ViesResult =
| { status: "valid"; name?: string; address?: string }
| { status: "invalid" }
| { status: "unavailable"; reason: string };
export async function checkVies(countryCode: string, vatNumber: string): Promise<ViesResult> {
// Normalize: Greece is EL in VIES, not GR
const normalized = countryCode.toUpperCase() === "GR" ? "EL" : countryCode.toUpperCase();
if (!VIES_COUNTRY_CODES.has(normalized)) {
return { status: "invalid" };
}
// Strip the country prefix if the caller included it in vatNumber
const cleanNumber = vatNumber.replace(/^[A-Z]{2}/i, "").trim();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 8000); // 8s timeout
try {
const response = await fetch(`${VIES_REST_BASE}/ms/${normalized}/vat/${cleanNumber}`, {
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
return { status: "unavailable", reason: `VIES HTTP ${response.status}` };
}
const raw = await response.json();
const parsed = ViesResponseSchema.safeParse(raw);
if (!parsed.success) {
return { status: "unavailable", reason: "Unexpected VIES response shape" };
}
const data = parsed.data;
// userError appears when the member state database is down
// MS_UNAVAILABLE, SERVICE_UNAVAILABLE, etc.
if (data.userError && data.userError !== "VALID") {
return { status: "unavailable", reason: data.userError };
}
if (!data.isValid) {
return { status: "invalid" };
}
return {
status: "valid",
name: data.name ?? undefined,
address: data.address ?? undefined,
};
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === "AbortError") {
return { status: "unavailable", reason: "VIES timeout" };
}
return { status: "unavailable", reason: "Network error" };
}
}
The key distinction is the three-state result: valid, invalid, and unavailable. Your business logic needs all three. "Unavailable" means you should not block the customer — you should either cache the last known result or apply a graceful fallback policy.
Redis Caching: The Part Everyone Skips
Calling VIES on every checkout request is a mistake for two reasons. First, it is slow (200–800ms). Second, VIES has an undocumented rate limit — bulk validation will get your IP blocked. In vatnode, I achieved a 95% cache hit rate using Redis with different TTLs for each result type.
// lib/vat-cache.ts
import { Redis } from "ioredis";
const redis = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 2,
lazyConnect: true,
});
// In-memory fallback for when Redis is unavailable
const memoryCache = new Map<string, { result: ViesResult; expiresAt: number }>();
const TTL = {
valid: 60 * 60 * 24 * 7, // 7 days — valid registrations are stable
invalid: 60 * 60 * 4, // 4 hours — they might fix their number and retry
unavailable: 60 * 5, // 5 minutes — retry VIES soon
} as const;
function cacheKey(countryCode: string, vatNumber: string): string {
return `vat:${countryCode.toUpperCase()}:${vatNumber.replace(/\s/g, "")}`;
}
export async function getCachedVatResult(
countryCode: string,
vatNumber: string
): Promise<ViesResult | null> {
const key = cacheKey(countryCode, vatNumber);
try {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached) as ViesResult;
} catch {
// Redis unavailable — fall through to memory cache
const mem = memoryCache.get(key);
if (mem && mem.expiresAt > Date.now()) return mem.result;
}
return null;
}
export async function setCachedVatResult(
countryCode: string,
vatNumber: string,
result: ViesResult
): Promise<void> {
const key = cacheKey(countryCode, vatNumber);
const ttl = TTL[result.status];
try {
await redis.set(key, JSON.stringify(result), "EX", ttl);
} catch {
// Redis unavailable — write to memory cache as fallback
memoryCache.set(key, { result, expiresAt: Date.now() + ttl * 1000 });
}
}
Do not cache unavailable results for too long. Five minutes is enough. A 4-hour TTL on unavailability means your Finnish customers are blocked for 4 hours after a 10-minute Vero maintenance window.
Validation Endpoint With Audit Logging
// app/api/vat/validate/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkVies } from "@/lib/vies";
import { getCachedVatResult, setCachedVatResult } from "@/lib/vat-cache";
import { db } from "@/lib/db";
import { vatValidationLog } from "@/lib/db/schema";
const RequestSchema = z.object({
countryCode: z.string().length(2),
vatNumber: z.string().min(4).max(20),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = RequestSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { countryCode, vatNumber } = parsed.data;
const cached = await getCachedVatResult(countryCode, vatNumber);
if (cached) {
return NextResponse.json({ ...cached, cached: true });
}
const result = await checkVies(countryCode, vatNumber);
await setCachedVatResult(countryCode, vatNumber, result);
// Audit log — essential for tax compliance
// You must be able to prove what VIES returned at the time of a transaction
await db.insert(vatValidationLog).values({
countryCode,
vatNumber,
result: result.status,
checkedAt: new Date(),
});
return NextResponse.json({ ...result, cached: false });
}
The audit log is not optional. Tax authorities can audit your transactions years later. You need to show what VIES returned when you made a sale. Without logging, you cannot reconstruct that audit trail.
B2B vs B2C: The Reverse Charge Mechanism
When you sell to another business in a different EU country, the VAT responsibility shifts from seller to buyer. This is called the reverse charge mechanism — you charge 0% VAT, include "Reverse charge" on the invoice, and the buyer accounts for VAT in their own country.
The rules:
- B2C (no VAT number): Charge VAT at the buyer's country rate. OSS registration applies for digital services.
- B2B (valid VAT number, different EU country): Reverse charge. Zero VAT.
- B2B (same country): Normal domestic VAT applies.
- B2B (VIES unavailable): Do not guess — queue for review.
// lib/vat-logic.ts
import type { ViesResult } from "./vies";
export type VatDecision =
| { type: "reverse_charge"; buyerVatNumber: string }
| { type: "domestic_vat"; rate: number }
| { type: "oss_vat"; rate: number; buyerCountry: string }
| { type: "pending_verification" }; // VIES unavailable — queue for review
interface TaxContext {
sellerCountryCode: string;
buyerCountryCode: string;
buyerVatNumber?: string;
viesResult?: ViesResult;
}
export function determineVatTreatment(ctx: TaxContext): VatDecision {
const { sellerCountryCode, buyerCountryCode, buyerVatNumber, viesResult } = ctx;
const isSameCountry = sellerCountryCode.toUpperCase() === buyerCountryCode.toUpperCase();
// B2B cross-border with confirmed valid VAT number = reverse charge
if (!isSameCountry && buyerVatNumber && viesResult?.status === "valid") {
return { type: "reverse_charge", buyerVatNumber };
}
// VIES was unavailable — cannot confirm reverse charge eligibility
if (!isSameCountry && buyerVatNumber && viesResult?.status === "unavailable") {
return { type: "pending_verification" };
}
// Domestic sale — always applies local VAT
if (isSameCountry) {
return { type: "domestic_vat", rate: getVatRate(sellerCountryCode) };
}
// Cross-border B2C — destination country rate (OSS)
return {
type: "oss_vat",
rate: getVatRate(buyerCountryCode),
buyerCountry: buyerCountryCode,
};
}
In pi-pi.ee, when VIES was unavailable, I queued the transaction for manual review rather than incorrectly applying or skipping VAT. You do not want to commit a tax error because a remote SOAP service had a 5-minute outage.
EU VAT Rates: Do Not Hardcode Them
VAT rates change. Hungary changed its standard rate. Ireland introduced new reduced rates. Portugal has different rates for the Azores and Madeira. If you hardcode rates, you will eventually charge the wrong amount.
I maintain an open-source package for this: eu-vat-rates-data, available on npm, PyPI, Go modules, RubyGems, and Packagist. Updated automatically via GitHub Actions whenever rates change.
npm install eu-vat-rates-data
// lib/vat-rates.ts
import vatRates from "eu-vat-rates-data";
type RateType = "standard" | "reduced" | "super_reduced" | "parking";
export function getVatRate(countryCode: string, type: RateType = "standard"): number {
const code = countryCode.toUpperCase() === "GR" ? "EL" : countryCode.toUpperCase();
const country = vatRates[code];
if (!country) {
throw new Error(`No VAT rates found for: ${countryCode}`);
}
return country.periods?.[0]?.rates?.[type] ?? country.periods?.[0]?.rates?.standard ?? 0;
}
const germanVat = getVatRate("DE"); // 19
const finnishVat = getVatRate("FI"); // 25.5
const greekVat = getVatRate("GR"); // 24 — EL normalization handled internally
Common Mistakes That Will Hurt You
1. Trusting VIES "invalid" unconditionally. Always check the userError field. Values like MS_UNAVAILABLE mean "I could not check" — not "this number is wrong." The wrapper above handles this correctly.
2. No format pre-validation. Every EU country has its own VAT number format. Validate format locally before calling VIES — it saves a round trip and gives faster user feedback. But format validation does not substitute for VIES; a correctly formatted number can still be deregistered.
3. No audit log. Tax authorities can audit transactions from years ago. Log every validation result with the VAT number, country code, VIES result, and timestamp. In EU jurisdictions, keep these for at least 10 years.
4. Ignoring the OSS threshold. There is a €10,000 annual threshold before OSS registration becomes mandatory for cross-border B2C digital services. Build threshold tracking into your billing system from day one.
5. Skipping the Greece normalization. ISO country code: GR. EU VAT prefix: EL. These are different in every system. Normalize GR → EL at the earliest point in your stack — before any validation logic runs.
Results From Production
In vatnode after implementing caching and three-state validation:
- 95% cache hit rate — most validation calls served from Redis, never touching VIES
- VIES rate limit never approached
- Zero checkout blocks due to VIES downtime —
unavailabletriggers a graceful fallback - Full audit trail queryable by VAT number or transaction ID
In pi-pi.ee covering 32 EU markets, reverse charge detection runs on every B2B order. The pending verification queue handles a handful of orders per week during VIES outages — the rest are processed automatically.
EU VAT compliance looks simple from the outside and becomes an ongoing engineering concern once you are in production. VIES reliability, caching strategy, audit trails, reverse charge logic, country-specific rate tables, format validation per member state — it adds up.
I have solved all of this across multiple production systems, from a dedicated VAT validation SaaS to multi-country B2B platforms covering 32 markets. If you are building a product for the European market and want to get the tax logic right from the start — get in touch. I am available for freelance projects and longer-term engagements.
Further reading:
- VIES official documentation and availability monitor
- EU OSS scheme for digital services
- eu-vat-rates-data on GitHub
- vatnode.dev — production EU VAT validation API
- Vatnode project case study — how the SaaS was built end-to-end
