Stripe VAT validation: validate customer VAT before charging
Add EU VAT validation to a Stripe checkout flow. Validate before creating the Customer, set tax_id_data, and handle reverse-charge for B2B correctly.
TL;DR
- Validate the buyer's VAT number with vatverify before creating any Stripe customer.
- Pass the validated VAT number as
tax_id_dataonstripe.customers.create. - Call
/v1/decideto get the correct mechanism (reverse_chargeorstandard) and wire the result into your Checkout Session.
@vatverify/node is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoints are live today. The same pattern works with a plain fetch call using Authorization: Bearer <key>. The Stripe integration logic stays identical.
Why Stripe Tax isn't always enough
Stripe Tax handles the easy case well: you enable it, attach an address, and Stripe calculates local rates for B2C sales. For many businesses that's fine. But B2B EU transactions have a different problem (the reverse-charge mechanism) and Stripe Tax does not make that decision for you. You still have to know whether your buyer holds a valid VAT registration, and whether that registration is in a different EU member state from you. Stripe can check that a VAT number looks correct (format), but it doesn't verify live registration against VIES. For B2B at scale, you also need to think about the 0.5% per-transaction fee that Stripe Tax adds. At €20k/month of EU B2B revenue that's €100/month extra just to do tax calculation that vatverify + /v1/decide handles for a fraction of that cost, with verified registration data.
What Stripe does for you
Stripe Tax automatically calculates sales tax, VAT, and GST based on the customer's billing address and your nexus settings. For B2C sales (consumers buying subscriptions or one-off products) this is genuinely great: you configure your product tax codes once, enable automatic_tax, and Stripe handles rate lookup and filing reminders.
When a customer provides a VAT ID in Checkout, Stripe applies zero-rating automatically if the VAT number passes format checks. This is the key limitation for B2B work.
What Stripe doesn't do well
Stripe validates VAT number format only. It does not check:
- Whether the number is currently registered in VIES
- Whether the company behind it matches what your customer told you
- Which specific mechanism applies to your seller country + buyer country pair
For non-EU sellers (US, UK, AU) selling into the EU, Stripe Tax has further gaps. You may need to register under OSS or in individual member states, and Stripe's guidance on that is limited.
For B2B reverse-charge, Stripe will zero-rate a customer with a VAT ID, but it relies on you to have confirmed that the ID is real. If the ID is invalid or deregistered and you zero-rate the sale, you are liable for the uncollected VAT.
The integration pattern
Three steps: validate, create customer, apply on checkout.
Step 1: Validate before creating Customer
import { Vatverify } from '@vatverify/node';
import Stripe from 'stripe';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createB2BCustomer(email: string, vatNumber: string) {
const result = await vat.validate({ vat_number: vatNumber });
if (!result.data.valid) {
throw new Error(`Invalid VAT: ${result.data.vat_number} is not registered`);
}
const company = result.data.company;
return stripe.customers.create({
email,
name: company?.name ?? result.data.vat_number,
address: { country: result.data.country.code, line1: company?.address ?? undefined },
tax_id_data: [{ type: 'eu_vat', value: vatNumber }],
});
}result.data.company?.name and result.data.company?.address come from VIES. They are the registered trading name and address, a stronger signal than whatever the customer typed into your form. company is nullable; some registries (notably Germany) do not publish company details.
Step 2: Use /v1/decide to get the mechanism
Once you have a valid VAT number, call /v1/decide to get the explicit tax decision for this seller + buyer combination:
import { Vatverify } from '@vatverify/node';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
export async function getTaxDecision({
sellerVat,
buyerVat,
}: {
sellerVat: string;
buyerVat: string;
}) {
const decision = await vat.decide({
seller_vat: sellerVat,
buyer_vat: buyerVat,
});
// decision.data.mechanism: 'standard' | 'reverse_charge' | 'zero_rated' | 'out_of_scope'
// (EU B2B checkout typically sees 'standard' for same-country sales,
// 'reverse_charge' for EU cross-border sales with a valid buyer VAT)
// decision.data.rate: 0 for reverse_charge / zero_rated, local rate for standard
// decision.data.invoice_note: ready-to-print legal string
// decision.data.legal_basis: e.g. 'EU VAT Directive Article 196'
// decision.data.charge_vat: boolean shorthand
return decision;
}The response for a German seller to a French buyer looks like:
{
"data": {
"mechanism": "reverse_charge",
"rate": 0,
"charge_vat": false,
"invoice_note": "Reverse charge — VAT to be accounted for by the recipient",
"buyer_vat": { "valid": true, "country": { "code": "FR", "name": "France" } }
}
}For a same-country B2B transaction (German seller, German buyer), data.mechanism is standard and data.rate is 19.
Step 3: Apply on Checkout Session
Wire the decision into your Stripe Checkout Session:
import Stripe from 'stripe';
import { Vatverify } from '@vatverify/node';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
export async function createB2BCheckoutSession({
email,
sellerVat,
buyerVat,
priceId,
}: {
email: string;
sellerVat: string;
buyerVat: string;
priceId: string;
}) {
// Validate buyer VAT
const vatResult = await vat.validate({ vat_number: buyerVat });
if (!vatResult.data.valid) {
throw new Error('VAT validation failed');
}
// Get tax mechanism
const decision = await vat.decide({
seller_vat: sellerVat,
buyer_vat: buyerVat,
});
// Create Stripe customer with verified data
const company = vatResult.data.company;
const customer = await stripe.customers.create({
email,
name: company?.name ?? vatResult.data.vat_number,
address: {
country: vatResult.data.country.code,
line1: company?.address ?? undefined,
},
tax_id_data: [{ type: 'eu_vat', value: buyerVat }],
metadata: {
vatverify_mechanism: decision.data.mechanism,
vatverify_rate: String(decision.data.rate),
vatverify_invoice_note: decision.data.invoice_note,
},
});
// For reverse_charge: automatic_tax handles zero-rating once tax_id_data is set
// For standard: automatic_tax applies the correct local rate
const session = await stripe.checkout.sessions.create({
customer: customer.id,
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
automatic_tax: { enabled: true },
tax_id_collection: { enabled: true },
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout`,
});
return { session, decision };
}When automatic_tax is enabled and the customer has a valid eu_vat tax ID, Stripe applies zero-rating automatically for cross-border EU B2B. The decision.data.mechanism is your audit trail confirming this was intentional.
Webhook validation
Re-validate VAT on the customer.tax_id.created webhook. This catches cases where a customer adds or updates their VAT ID after the initial purchase:
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { Vatverify } from '@vatverify/node';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
if (event.type === 'customer.tax_id.created') {
const taxId = event.data.object as Stripe.TaxId;
if (taxId.type === 'eu_vat' && taxId.value) {
const result = await vat.validate({ vat_number: taxId.value });
if (!result.data.valid) {
// Flag for manual review: do not silently accept an unregistered VAT ID
await stripe.customers.update(taxId.customer as string, {
metadata: { vat_review_required: 'true' },
});
}
}
}
return NextResponse.json({ received: true });
}Reverse-charge invoice pattern
Inject decision.data.invoice_note into the Stripe invoice footer so the legal text appears on every PDF invoice automatically:
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function setInvoiceFooter(
customerId: string,
invoiceNote: string
) {
// Set on the customer so every future invoice inherits it
await stripe.customers.update(customerId, {
invoice_settings: {
footer: invoiceNote,
},
metadata: {
vatverify_invoice_note: invoiceNote,
},
});
}The invoice_note from /v1/decide arrives ready to print and is paired with legal_basis ("EU VAT Directive Article 196") so you can put both on the invoice to satisfy Article 226. A typical invoice_note for a cross-border B2B sale: "Reverse charge — VAT to be accounted for by the recipient".
Failure modes
vatverify is down: Cached responses with meta.source_status: "degraded" keep working during VIES outages. If vatverify itself is unreachable (network error, 5xx), two safe options: fall back to a format check (via @vatverify/vat-rates) and flag the transaction for manual review, or block checkout and show a friendly retry message. Never silently zero-rate without a confirmed valid VAT. You carry the liability.
VAT is invalid: Surface the error inline before checkout reaches Stripe. If result.data.valid is false, the buyer's number is not registered with the upstream registry. If the SDK threw a ValidationError with code invalid_format, the string never even reached the registry. Show the customer the normalised vat_number and ask them to re-enter it.
/v1/decide returns buyer_vat_not_registered: Same treatment as invalid VAT. Stop checkout and prompt the customer to check their VAT number. A well-formatted VAT number that fails VIES means the registration has lapsed or the format is valid but the number was never issued.
FAQ
Can I do this entirely client-side?
No. Use a server action (Next.js App Router) or a route handler. Your VATVERIFY_API_KEY must never reach the browser. A client-side validation call would expose your API key in network requests. The validateVat server action pattern in the Node.js guide shows the correct approach.
Stripe Tax is cheaper for small volumes. When does vatverify + /decide win?
Stripe Tax charges 0.5% per transaction. At €20,000/month in EU B2B revenue that's €100/month. vatverify's paid plans start well below that, and you get confirmed live registration (not format-only). The crossover depends on your transaction volume and average order value, but EU-heavy B2B SaaS at scale almost always saves money with explicit VAT validation. The bigger advantage is accuracy: format-only validation can zero-rate a sale you should have taxed, leaving you liable.
What about Stripe's built-in VAT ID validation?
Stripe validates format only. It checks that the number matches the expected pattern for the country prefix. vatverify validates format, checksum (MOD-11, MOD-97, and country-specific algorithms), and live registration against VIES. A number can pass Stripe's format check and still be unregistered or belong to a deregistered company.
Does /v1/decide handle OSS / IOSS?
Not in v1. The endpoint is B2B only. OSS (One Stop Shop) and IOSS for B2C distance selling are tracked for v2. The request requires both seller_vat and buyer_vat; if no buyer VAT is available, the API returns a b2c_not_supported error. This is intentional, not a bug.
Can I use Stripe Tax AND vatverify together?
Yes, many customers run both. The typical pattern: use vatverify for VAT registration validity and /v1/decide for the reverse-charge decision; use Stripe Tax for rate calculation and filing reminders. Stripe Tax is good at the mechanical part of tax calculation. vatverify is good at telling you whether the buyer is actually VAT-registered and what mechanism applies.
How do I handle downtime?
vatverify returns cached responses automatically during VIES outages. result.meta.source_status will be "degraded" but the response is still usable. If vatverify itself is down, the safest path is to block checkout and show a retry message rather than silently accepting unvalidated VAT numbers. See handle-vies-downtime for detailed retry and fallback patterns.