vatverify home
All guides

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/decide to get invoice_note and inject it into your PDF invoice footer.
SDK status

@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-client

Validating before payment

The pattern mirrors the Stripe integration: validate VAT first, then create the Mollie customer and payment.

lib/mollie-b2b-payment.ts
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:

lib/mollie-invoice.ts
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:

lib/subscription-renewal.ts
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:

tests/mollie-payment.test.ts
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.

Validate VAT in three lines.

Free up to 500 requests per month. No credit card.

Start free