Mollie VAT validation: EU B2B checkout with reverse-charge
Validate customer VAT before a Mollie payment. Add reverse-charge logic to EU B2B invoices using /v1/decide.
TL;DR
- Validate the buyer's VAT number with vatverify before creating the Mollie customer.
- Create the Mollie payment with the validated company name and VAT metadata.
- Call
/v1/decideto getinvoice_noteand inject it into your PDF invoice footer.
@vatverify/node is packaged and ships on npm at the API launch. Reading this before launch? The REST endpoints (/v1/validate and /v1/decide) are live today. The pattern in this guide works identically with a plain fetch call using the same Authorization: Bearer header.
Why Mollie + vatverify?
Mollie is a payment processor, not a tax engine. It handles the money movement reliably across iDEAL, SEPA, credit cards, and a dozen other methods, but has no concept of whether your customer is VAT-registered or what reverse-charge mechanism applies. For EU B2B sellers using Mollie, that gap is your problem. You need to verify the buyer's VAT number is real (not just well-formatted), determine whether you charge VAT or apply reverse-charge, and get the correct legal note onto the invoice. vatverify slots in before you touch the Mollie API at all. Validate first, then pay.
Installation
npm install @vatverify/node @mollie/api-clientValidating before payment
The pattern mirrors the Stripe integration: validate VAT first, then create the Mollie customer and payment.
import { Vatverify } from '@vatverify/node';
import { createMollieClient } from '@mollie/api-client';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! });
export async function createB2BPayment({
email,
sellerVat,
buyerVat,
amountEur,
description,
redirectUrl,
}: {
email: string;
sellerVat: string;
buyerVat: string;
amountEur: string; // e.g. "99.00"
description: string;
redirectUrl: string;
}) {
// Step 1: validate buyer VAT number
const vatResult = await vat.validate({ vat_number: buyerVat });
if (!vatResult.data.valid) {
throw new Error('Invalid VAT number');
}
// Step 2: get tax decision
const decision = await vat.decide({
seller_vat: sellerVat,
buyer_vat: buyerVat,
});
// Step 3: create Mollie customer with validated company data
const customer = await mollie.customers.create({
name: vatResult.data.company?.name ?? vatResult.data.vat_number,
email,
metadata: {
vatNumber: buyerVat,
vatCountry: vatResult.data.country.code,
vatMechanism: decision.data.mechanism,
vatRate: String(decision.data.rate),
invoiceNote: decision.data.invoice_note,
},
});
// Step 4: create Mollie payment
const payment = await mollie.payments.create({
amount: {
currency: 'EUR',
value: amountEur,
},
description,
redirectUrl,
customerId: customer.id,
metadata: {
vatNumber: buyerVat,
mechanism: decision.data.mechanism,
invoiceNote: decision.data.invoice_note,
},
});
return { payment, customer, decision };
}Decide + invoice generation
After calling /v1/decide, inject decision.data.invoice_note into your invoice PDF. The note arrives ready to print and satisfies Article 226 of the EU VAT Directive:
import { Vatverify } from '@vatverify/node';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
interface InvoiceLineItem {
description: string;
unitPrice: number;
quantity: number;
}
export async function buildMollieInvoice({
sellerVat,
buyerVat,
lineItems,
}: {
sellerVat: string;
buyerVat: string;
lineItems: InvoiceLineItem[];
}) {
const decision = await vat.decide({
seller_vat: sellerVat,
buyer_vat: buyerVat,
});
const subtotal = lineItems.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0
);
const vatAmount = subtotal * (decision.data.rate / 100);
const total = subtotal + vatAmount;
return {
lineItems,
subtotal,
vatRate: decision.data.rate,
vatAmount,
total,
// Print this string verbatim in the invoice footer
legalFooter: decision.data.invoice_note,
mechanism: decision.data.mechanism,
};
}For a reverse-charge transaction, decision.data.rate is 0, vatAmount is 0, and legalFooter is "Reverse charge — VAT to be accounted for by the recipient" (paired with decision.data.legal_basis: "EU VAT Directive Article 196"). Ready to print.
Recurring subscriptions
For B2B subscriptions billed monthly, re-validate the VAT number on each invoice cycle. vatverify's 30-day cache means monthly re-checks are free for most active customers:
import { Vatverify } from '@vatverify/node';
import { createMollieClient } from '@mollie/api-client';
const vat = new Vatverify(process.env.VATVERIFY_API_KEY!);
const mollie = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! });
export async function renewalCheck(
mollieCustomerId: string,
sellerVat: string,
buyerVat: string
) {
// Re-validate, returns from cache if checked within 30 days
const vatResult = await vat.validate({ vat_number: buyerVat });
if (!vatResult.data.valid) {
// VAT deregistered since last check: pause subscription, notify customer
await mollie.customers.update(mollieCustomerId, {
metadata: { vatStatus: 'deregistered', reviewRequired: 'true' },
});
throw new Error(`Customer VAT deregistered: ${buyerVat}`);
}
// Refresh the tax decision for this period
const decision = await vat.decide({
seller_vat: sellerVat,
buyer_vat: buyerVat,
});
return decision;
}Monthly caching works because VIES registration status rarely changes mid-cycle. If vatResult.meta.source_status is "degraded" (returned during a VIES outage), the cached data is still usable and the check is free.
Testing
Combine Mollie's test API key with vatverify's vtv_test_* keys for fully isolated testing:
import { Vatverify } from '@vatverify/node';
import { createMollieClient } from '@mollie/api-client';
// Both clients use test credentials; no real calls made
const vat = new Vatverify('vtv_test_xxx');
const mollie = createMollieClient({ apiKey: 'test_xxx' });
test('valid VAT creates Mollie customer with reverse-charge metadata', async () => {
const result = await vat.validate({ vat_number: 'FR40303265045' });
expect(result.data.valid).toBe(true);
const decision = await vat.decide({
seller_vat: 'DE811569869',
buyer_vat: 'FR40303265045',
});
expect(decision.data.mechanism).toBe('reverse_charge');
expect(decision.data.rate).toBe(0);
expect(decision.data.legal_basis).toBe('EU VAT Directive Article 196');
expect(decision.data.invoice_note).toContain('Reverse charge');
});
test('invalid VAT throws before touching Mollie', async () => {
const result = await vat.validate({ vat_number: 'FR00000000000' });
expect(result.data.valid).toBe(false);
});FAQ
Does Mollie validate VAT numbers?
No. Mollie is a payment processor. It moves money, not tax logic. VAT validation, registration checks, and reverse-charge decisions are entirely your responsibility. vatverify is the layer that fills this gap before you call the Mollie API.
Can I add the VAT number as Mollie customer metadata?
Yes. The metadata field on a Mollie customer accepts arbitrary key-value pairs. The convention in the example above is metadata.vatNumber and metadata.vatCountry. Keep the format consistent across your codebase so you can query it reliably.
What about Mollie Connect for marketplaces?
Same pattern. Just validate for each sub-merchant and buyer separately. In a marketplace, you may have multiple seller VAT numbers. Run /v1/decide once per (seller_vat, buyer_vat) combination. Mollie Connect's routing is orthogonal to VAT logic.
How do I handle different currencies?
Mollie supports EUR, GBP, USD, and others. vatverify's /v1/decide always returns rate as a percentage to apply to the invoice subtotal. The currency is irrelevant to the VAT mechanism. Apply decision.data.rate / 100 * subtotalInYourCurrency regardless of which currency the Mollie payment is in.
Is SEPA direct debit affected differently?
No. SEPA direct debit is a payment method, not a different billing model. The same VAT validation pattern applies: validate the VAT number before creating the Mollie mandate, get the tax decision, and inject the invoice note into the mandate's associated invoice.