Getting VAT validation right at checkout matters more than it looks. A developer who adds a VAT number field and skips server-side verification, or collapses 'VIES unavailable' and 'invalid number' into the same error, creates a compliance gap that does not show up in tests but becomes a tax liability during audit. This guide covers the complete flow: what to show the buyer, how to validate on the server, what to do when the registry is down, and how to connect the result to your billing provider.
The Checkout VAT Flow: Four Steps
The correct checkout VAT flow has four distinct steps: (1) collect the VAT number from the buyer in the frontend, (2) validate it server-side via the TaxID API before the order is created, (3) apply zero-rate treatment in your billing provider if the validation returns active, and (4) store the validation result as an audit record attached to the invoice. Each step has specific failure modes that need handling.
Step 1: The Frontend VAT Field
Show the VAT number field only when the customer selects a business billing type or enters a country that requires B2B tax handling. Displaying it to all customers increases form abandonment and produces noise from consumers who will enter invalid data. A good pattern: show a 'I am purchasing on behalf of a company' toggle in the billing address section, and reveal the VAT number field only when that toggle is on.
export function VatField({ country }: { country: string }) {
const [isBusiness, setIsBusiness] = useState(false);
const [vat, setVat] = useState('');
const [status, setStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid' | 'unavailable'>('idle');
async function checkVat() {
if (!vat || !country) return;
setStatus('checking');
const res = await fetch('/api/validate-vat', {
method: 'POST',
body: JSON.stringify({ country, vat }),
headers: { 'Content-Type': 'application/json' },
});
const data = await res.json();
setStatus(data.status === 'active' ? 'valid' : data.status === 'service_unavailable' ? 'unavailable' : 'invalid');
}
return (
<div>
<label>
<input type="checkbox" checked={isBusiness} onChange={e => setIsBusiness(e.target.checked)} />
I am purchasing on behalf of a company
</label>
{isBusiness && (
<div>
<input
type="text"
value={vat}
onChange={e => setVat(e.target.value)}
onBlur={checkVat}
placeholder={`${country} VAT number (optional)`}
/>
{status === 'valid' && <span className="text-green-600">✓ Valid — VAT will not be charged</span>}
{status === 'invalid' && <span className="text-red-600">This VAT number was not found in the registry</span>}
{status === 'unavailable' && <span className="text-amber-600">Registry temporarily unavailable — VAT will be charged and refunded once verified</span>}
</div>
)}
</div>
);
}Note
Never validate the VAT number from the browser by calling the TaxID API directly. Your API key would be exposed in network requests. Always proxy through your own server endpoint.
Step 2: Server-Side Validation Endpoint
Create a server endpoint that the frontend calls. This endpoint calls the TaxID API with your server-side API key, handles the three distinct response states (active, invalid, service_unavailable), and returns a clean status to the frontend.
// Next.js App Router — POST /api/validate-vat
export async function POST(req: Request) {
const { country, vat } = await req.json();
if (!country || !vat) {
return Response.json({ status: 'format_invalid' }, { status: 400 });
}
const res = await fetch(
`https://taxid.dev/api/v1/validate/${country}/${encodeURIComponent(vat)}`,
{
headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` },
// 5-second timeout — VIES can be slow
signal: AbortSignal.timeout(5000),
}
);
if (!res.ok) {
// Treat API errors as service_unavailable — do not block checkout
return Response.json({ status: 'service_unavailable' });
}
const data = await res.json();
// Return only what the frontend needs — do not expose the full API response
return Response.json({
status: data.status,
companyName: data.company_name,
address: data.address,
requestId: data.request_id,
});
}Step 3: Applying Zero-Rate in Stripe
When the validation returns active, update the Stripe customer object to set tax_exempt: 'reverse'. This causes Stripe Tax to suppress VAT on all subsequent invoices for that customer and adds the 'Reverse charge' annotation required by EU Invoice Directive 2006/112/EC Article 226.
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function applyVatExemption(
stripeCustomerId: string,
vatValidation: { status: string; companyName: string | null; requestId: string; vat: string }
) {
if (vatValidation.status !== 'active') {
throw new Error(`Cannot apply zero-rate: VAT status is ${vatValidation.status}`);
}
await stripe.customers.update(stripeCustomerId, {
tax_exempt: 'reverse',
metadata: {
vat_number: vatValidation.vat,
vat_company_name: vatValidation.companyName ?? '',
vat_validated_at: new Date().toISOString(),
vat_request_id: vatValidation.requestId,
},
});
}Step 4: Storing the Audit Record
EU record-keeping obligations require you to show, on audit, that the VAT number was valid at the time of every zero-rated invoice. The minimum audit record contains: the validated VAT number, the company name and address returned by VIES, the validation timestamp, and the request_id from the API response. Store these with the invoice, not just with the customer — VAT registrations can change between invoices.
-- Minimal audit table structure
CREATE TABLE vat_validation_records (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id uuid REFERENCES invoices(id),
customer_id uuid REFERENCES customers(id),
vat_number text NOT NULL,
company_name text,
registered_address text,
validation_status text NOT NULL, -- active | invalid | service_unavailable
api_request_id text,
validated_at timestamptz NOT NULL DEFAULT now()
);Handling the service_unavailable State
VIES has documented reliability issues. Some member-state nodes have scheduled maintenance windows without advance notice. When the API returns service_unavailable, you have two safe options: charge standard VAT and issue a credit note once VIES recovers, or queue the customer for re-validation and hold the zero-rate decision until confirmed. Never silently apply zero-rate to an unverified number — that is exactly the liability you are trying to avoid.
Warning
Do not block checkout when VIES is unavailable. Blocking legitimate business customers during an EU maintenance window creates more commercial damage than the compliance risk of delaying zero-rate application by a few hours. Charge standard VAT, log the service_unavailable event, and process the adjustment once the registry recovers.
Re-Validation for Subscriptions
One-time checkout validation is insufficient for subscription products. Add a background job that re-validates every customer with tax_exempt: 'reverse' at least monthly. The TaxID API supports batch validation for this use case. When a previously valid number returns inactive or invalid, flag it for your finance team before the next billing cycle and revert tax_exempt to none until the customer updates their registration.
// Run monthly via cron
export async function revalidateReverseChargeCustomers() {
const customers = await db.query(
`SELECT customer_id, vat_number FROM vat_validation_records
WHERE validation_status = 'active'
GROUP BY customer_id, vat_number
HAVING MAX(validated_at) < now() - interval '30 days'`
);
for (const customer of customers) {
const [country, ...rest] = [customer.vat_number.slice(0, 2), customer.vat_number.slice(2)];
const res = await fetch(
`https://taxid.dev/api/v1/validate/${country}/${rest.join('')}`,
{ headers: { Authorization: `Bearer ${process.env.TAXID_API_KEY}` } }
);
const data = await res.json();
if (data.status !== 'active') {
await flagForFinanceReview(customer.customer_id, data.status);
}
await db.query(
`INSERT INTO vat_validation_records (customer_id, vat_number, validation_status, api_request_id)
VALUES ($1, $2, $3, $4)`,
[customer.customer_id, customer.vat_number, data.status, data.request_id]
);
}
}For more on the legal requirements behind this integration, see Ensuring EU VAT Compliance for SaaS Businesses. For Paddle-specific integration (which handles VAT differently from Stripe), the same server-side validation pattern applies but you use Paddle's API to set the tax override rather than Stripe's tax_exempt flag.
Common Integration Mistakes to Avoid
Several integration patterns look correct in development but create compliance gaps in production. The most common is validating in the browser via a direct API call — this exposes your API key in network requests and can be easily bypassed by a determined user who simply removes the field from their form payload. Always proxy through your own server endpoint.
The second most common mistake is treating the VAT field as required. VAT numbers are optional — consumers do not have them, and many small businesses that have not yet crossed the VAT registration threshold do not have them either. Making the field required creates friction that causes abandonment, particularly from legitimate B2C customers. The field should always be optional, with checkout proceeding normally if it is left blank (standard VAT applies).
Third: storing only the valid/invalid result, not the full API response. An audit asks what the VIES registry said at the time of the transaction — not a boolean you computed from it. Store the complete response including status, company_name, address, and request_id. The request_id is particularly important: it is the unique identifier for the underlying VIES query and is the single piece of evidence that proves a live government registry was consulted.
Testing Your VAT Integration
Use the TaxID API test numbers to verify that your integration handles all status codes correctly before going live. The test numbers are documented in the API reference: DE000000000 returns format_invalid (format check fails before VIES), DE999999999 returns invalid (VIES confirms no such registration), and DE888888888 returns service_unavailable (simulates a VIES downtime event). Testing the service_unavailable path is critical — most integrations only test the happy path and the invalid path, leaving the downtime handling untested until the first real VIES maintenance window hits production.