Reverse-Charge VAT for B2B SaaS: Node.js Implementation Checklist
1 June 2026 · 9 min read

Most EU B2B SaaS billing systems converge on the same flow: the buyer enters a VAT ID at checkout, the seller validates it, and if the validation passes the invoice is issued without VAT and labelled as a reverse-charge supply. The mechanism itself is well-documented in Council Directive 2006/112/EC — Article 44 (place of supply of services to a taxable person) and Article 196 (the reverse charge for services). What is less well-documented is what your code actually has to do to implement it correctly. This is that checklist.
This post focuses on SaaS — i.e. services, not goods. Article 138 (intra-EU supply of goods) and the related goods-side rules sit on a different code path and are out of scope here. Scope-wise, this post also stops at the invoice-issuance boundary: credit notes, partial refunds, subscription proration, jurisdiction-specific invoice immutability rules, and the e-invoicing / Peppol direction that EU member states are converging on each deserve their own treatment.
The reverse-charge concept guide covers the legal frame. This post is the engineering counterpart: what to validate, when to flag, what to store, and where the real-world edge cases sit.
Preconditions: when reverse-charge applies
Before any code runs, four conditions need to hold for a B2B SaaS supply to qualify for reverse-charge treatment:
- You (the seller) are established in one EU member state. If you are outside the EU but selling into it, different rules apply — OSS / IOSS are simplification schemes for distance sales and certain B2C / low-value flows; they sit alongside reverse-charge, not in opposition to it, and a non-EU seller's path to B2B EU customers may still involve a reverse-charge treatment under local rules. They are not the topic of this post.
- The buyer is established in a different EU member state from you.
- The buyer is a taxable person — i.e. a business registered for VAT — and you can demonstrate it. National tax authorities accept "reasonable evidence" of taxable-person status, but a VIES-confirmed validation with a stored consultation number is the strongest practical standard most SaaS companies can produce and the one auditors are most likely to expect by default.
- The service falls under the general B2B rule (Article 44). For most SaaS — software access, hosting, support — it does. Edge cases (telecoms, broadcasting, certain digital services to specific buyers) can land under different place-of-supply rules.
If any of these fails, your invoice is not a reverse-charge invoice. Domestic supplies, B2C supplies, and supplies to buyers in your own member state all charge VAT normally.
The most common bug we see in early-stage B2B SaaS billing is treating "the buyer entered a VAT ID" as sufficient. It isn't. The VAT ID has to be valid in VIES, the buyer's country has to differ from yours, and you have to keep evidence of the check. Skipping any one of those puts you in the position of having issued zero-VAT invoices that an auditor can reclassify as VAT-inclusive, with you on the hook for the missing VAT.
The decision function
The whole thing collapses into a single decision function that runs on every B2B invoice. Everything else in this post is what feeds it.
type ReverseChargeDecision =
| { applyReverseCharge: true; evidence: VatEvidence }
| { applyReverseCharge: false; reason: ReverseChargeBlockedReason }
type ReverseChargeBlockedReason =
| 'BUYER_SAME_COUNTRY_AS_SELLER'
| 'BUYER_VAT_INVALID'
| 'BUYER_VAT_NOT_PROVIDED'
| 'BUYER_OUTSIDE_EU'
| 'VIES_UNAVAILABLE_NO_FALLBACK'
interface VatEvidence {
vatId: string
source: 'VIES' | 'NATIONAL_FALLBACK'
consultationNumber: string | null
traderName: string | null
traderAddress: string | null
checkedAt: string // ISO timestamp
}
function decideReverseCharge(input: {
sellerCountry: string
buyer: { country: string; vatId: string | null }
vatCheck: VatCheckResult | null
}): ReverseChargeDecision {
if (!input.buyer.vatId) {
return { applyReverseCharge: false, reason: 'BUYER_VAT_NOT_PROVIDED' }
}
if (!isEUCountry(input.buyer.country)) {
return { applyReverseCharge: false, reason: 'BUYER_OUTSIDE_EU' }
}
if (input.buyer.country === input.sellerCountry) {
return { applyReverseCharge: false, reason: 'BUYER_SAME_COUNTRY_AS_SELLER' }
}
if (!input.vatCheck || !input.vatCheck.valid) {
return { applyReverseCharge: false, reason: 'BUYER_VAT_INVALID' }
}
return {
applyReverseCharge: true,
evidence: {
vatId: input.vatCheck.vatId,
source: input.vatCheck.source,
consultationNumber: input.vatCheck.consultationNumber,
traderName: input.vatCheck.name,
traderAddress: input.vatCheck.address,
checkedAt: input.vatCheck.checkedAt,
},
}
}
That function should be pure, deterministic, and called from exactly one place in your billing code path. If reverse-charge decisions are scattered across the checkout UI, the invoice renderer, and the Stripe webhook handler, the audit trail will be impossible to reason about later.
Step 1: validate the buyer VAT ID
The validation step is the one place where "reasonable steps to verify" is operationalised in code. A VIES-confirmed validation, with a consultation number and a stored result, is the strongest evidence most SaaS companies can produce.
The full validation pipeline is covered in the Node.js VAT validation guide; the relevant outputs here are:
valid(boolean)source—VIESor a national-fallback source when VIES was degradedconsultationNumber— the requester-qualified reference described in the VIES consultation number guidenameandaddress— the trader details VIES returns (when not opted out)checkedAt— the timestamp of the check
What matters for reverse-charge specifically:
- The validation must be requester-qualified. Call
checkVatApproxwith your own VAT ID as the requester, not the barecheckVat. Only the requester-qualified call produces a consultation number. - A "valid" result from a national-registry fallback is not equivalent to a VIES "valid". A buyer can be active in a national company registry without being VAT-registered for intra-EU operations. If your fallback path returned a non-VIES source, flag the invoice for re-validation once VIES recovers, and record
requires_recheck = true. - A
MS_UNAVAILABLEfrom VIES is not "invalid". It is a transport signal. Handle it with retry and fallback patterns as covered in the VIES downtime guide; do not flip the buyer to a VAT-charged invoice just because VIES is having a bad afternoon.
Step 2: the country-comparison rule
Reverse-charge applies only when buyer and seller are in different EU member states. The two ways teams get this wrong:
- Using the buyer's billing address country instead of the country of the VAT ID. They should match (and you should require them to match), but if they do not, you cannot resolve the ambiguity yourself. Place of supply is determined by where the buyer is established as a taxable person, not by either the billing address or the VAT ID prefix in isolation — but for an automated checkout, the VAT ID prefix is the strongest signal you have, and a mismatch between it and the billing country is a signal to stop and ask. If a Spanish company with
ES-prefixed VAT ID enters a Portuguese billing address, reject the mismatch at checkout rather than guess. - Treating domestic intra-country supplies as reverse-charge. A German seller selling to a German buyer charges 19% German VAT. Reverse-charge does not apply, regardless of how clean the buyer's VAT ID is.
function isReverseChargeEligibleByGeography(
sellerCountry: string,
buyerVatId: string,
buyerBillingCountry: string,
): { ok: boolean; reason?: string } {
const vatPrefix = buyerVatId.slice(0, 2).toUpperCase()
if (vatPrefix !== buyerBillingCountry.toUpperCase()) {
return { ok: false, reason: 'VAT_ID_COUNTRY_DOES_NOT_MATCH_BILLING' }
}
if (!isEUCountry(vatPrefix)) {
return { ok: false, reason: 'BUYER_OUTSIDE_EU' }
}
if (vatPrefix === sellerCountry.toUpperCase()) {
return { ok: false, reason: 'BUYER_SAME_COUNTRY_AS_SELLER' }
}
return { ok: true }
}
Note that the EU country list is a moving target on the margins — Brexit removed GB, and XI (Northern Ireland) is special-cased for goods only. Pull the membership check from a maintained source rather than hardcoding it; the eu-vat-rates-data package exposes an isEUMember() helper that tracks this for you.
Step 3: render the invoice correctly
The invoice itself has formal requirements when reverse-charge applies. From Article 226 of Directive 2006/112/EC, invoices for reverse-charge supplies must include:
- The buyer's VAT identification number
- The seller's VAT identification number
- A reference to the reverse-charge mechanism — typically the wording "Reverse charge" (Article 226(11a))
- A reference to the legal basis, when local rules require it (e.g., "Article 196 of Directive 2006/112/EC" or the national-law equivalent)
- Zero VAT amount and a clear line that VAT is not charged
In practice, the minimum that satisfies tax authorities across member states is the phrase "Reverse charge" plus the buyer VAT ID. Some authorities expect the legal citation; if you serve customers across all 27 member states, include it.
function renderInvoiceTotals(invoice: Invoice, decision: ReverseChargeDecision) {
if (decision.applyReverseCharge) {
return {
subtotal: invoice.subtotal,
vatRate: 0,
vatAmount: 0,
total: invoice.subtotal,
vatNote:
'Reverse charge — VAT to be accounted for by the recipient ' +
'(Article 196 of Council Directive 2006/112/EC).',
buyerVatId: decision.evidence.vatId,
sellerVatId: invoice.seller.vatId,
}
}
const vatRate = lookupVatRate(invoice.buyer.country, invoice.lineItems)
return {
subtotal: invoice.subtotal,
vatRate,
vatAmount: round2(invoice.subtotal * vatRate),
total: round2(invoice.subtotal * (1 + vatRate)),
vatNote: null,
buyerVatId: invoice.buyer.vatId ?? null,
sellerVatId: invoice.seller.vatId,
}
}
If you are using Stripe, the tax_behavior and customer_tax_ids fields on Invoice / Subscription objects participate in this — Stripe will mark the line as reverse-charge if you have configured Stripe Tax appropriately, but the legal responsibility for the invoice contents remains yours. Treat Stripe's tax engine as a helper, not as the source of truth for the wording on the document.
Step 4: store audit evidence
The single most useful audit habit is to write the evidence next to the invoice, not in a separate validation log nobody links back. A reverse-charge invoice row should carry, at minimum:
ALTER TABLE invoices ADD COLUMN reverse_charge_applied boolean NOT NULL DEFAULT false;
ALTER TABLE invoices ADD COLUMN buyer_vat_id text;
ALTER TABLE invoices ADD COLUMN buyer_vat_source text; -- 'VIES' | 'NATIONAL_FALLBACK'
ALTER TABLE invoices ADD COLUMN buyer_vat_consultation_no text;
ALTER TABLE invoices ADD COLUMN buyer_vat_checked_at timestamptz;
ALTER TABLE invoices ADD COLUMN buyer_vat_trader_name text;
ALTER TABLE invoices ADD COLUMN buyer_vat_requires_recheck boolean NOT NULL DEFAULT false;
The reason to denormalise this onto the invoice — rather than keep it as a foreign key to a vat_checks table — is that VAT IDs can be deregistered after the fact. The evidence you care about is the state at the moment of the supply. If you only keep a pointer to the latest check, a buyer whose VAT ID expires next year will appear "invalid" against last year's invoices, and you will not be able to reconstruct what your audit posture was when you issued them. The broader VAT audit trail guide covers the cross-table schema.
A background job should reconcile requires_recheck = true rows against VIES when the relevant national node recovers, and update source, consultation_no, and requires_recheck accordingly. The invoice itself does not change — what changes is the evidence row attached to it.
One more thing worth surfacing in the schema: retention. EU member states set their own statutory retention periods for invoices and supporting tax evidence (commonly 5–10 years, jurisdiction-dependent), and the audit evidence attached to a reverse-charge invoice falls under the same regime as the invoice itself. Pick the longest retention horizon among the jurisdictions you serve and use it as the floor; do not let log-rotation or "tidying up old rows" routines silently truncate this table.
Step 5: handle the edge cases
These are the cases most early implementations miss:
- VAT ID deregistered after the invoice was issued. Not your problem at the moment of supply, provided the validation was genuine at the time. Keep the original
checked_atandconsultation_no; the audit position is "valid at time of supply", not "still valid today". - Buyer changes their VAT ID mid-subscription. Treat as a new validation. Re-validate, store fresh evidence on the next invoice, and consider whether your subscription model needs a
vat_id_historyaudit trail. - Buyer downgrades from B2B to B2C (e.g., they remove the VAT ID from their account). Subsequent invoices are no longer reverse-charge. Make sure your subscription renewal path re-evaluates the decision function on every invoice, not just at sign-up.
- You change member state. Rare but real for early-stage companies relocating. From the relocation date forwards, the country-comparison rule changes for every existing customer. Re-evaluate.
- VIES says invalid for a known-good customer. Surface a clear UX path: keep the customer on a paid plan (do not lock them out), invoice with VAT temporarily (depending on local invoicing rules — in some jurisdictions a later correction via credit note plus reissued invoice is the cleaner path than holding the invoice in limbo), and let them re-submit the VAT ID. Cache the failure for a short window only (5–15 minutes), as discussed in the VIES downtime guide.
MS_UNAVAILABLEat invoice-generation time. This is the most subtle case. You cannot prove the VAT ID is currently valid, but you may have a fresh successful check from an hour ago. The defensible position is: rely on the most recent successful VIES check within a documented cache window (often 24 hours), and queue an async re-check. Do not silently flip the invoice to VAT-inclusive in response to a transient VIES outage.- Buyer in Northern Ireland (
XIprefix). Post-Brexit,XIexists under the Windsor Framework specifically for the intra-EU goods regime — Northern Ireland businesses dealing in goods remain inside the EU VAT system for those flows, even though the rest of the UK does not. For SaaS (services),XIis not the right identifier; the relevant prefix for UK-based buyers isGB, which is outside the EU VAT system entirely. TreatGBasBUYER_OUTSIDE_EUfor the reverse-charge decision, and if a buyer presents anXIVAT ID for a services supply, treat the prefix as a signal to ask for the underlyingGBVAT ID rather than blindly accepting it.
The minimum viable implementation checklist
If you implement nothing else, implement this list:
- Validate the buyer VAT ID through VIES with a requester-qualified call.
- Store the consultation number, source, trader name, and
checked_atnext to the invoice. - Re-run the decision function on every invoice, not just at sign-up.
- Render "Reverse charge — Article 196 of Directive 2006/112/EC" on the invoice when the decision is true.
- Have a background job that flips
requires_recheck = falseonce VIES has confirmed a fallback-sourced validation. - Never silently flip a customer from reverse-charge to VAT-inclusive in response to a transient VIES outage.
Everything else — checkout UX, tax-rate lookup for non-reverse-charge cases, OSS/IOSS for non-EU buyers — sits on top of those six.
Skip the pipeline boilerplate
vatnode runs the full requester-qualified VIES call (with consultation number), national-registry fallback when VIES is degraded, and a stable response shape across all 27 member states. If you want to keep the decision function in your own code but stop maintaining SOAP, fallback clients, and per-country regex, that is what the API is for.
Get a free API key · VIES API alternative · Reverse-charge concept guide