Reverse Charge B2B VAT Validation with VIES

You face this the instant a customer in another EU member state enters a VAT number at checkout. A validated cross-border B2B VAT number flips the supply to the reverse-charge mechanism: you charge 0% VAT and the customer self-accounts in their own return. Get it wrong β€” apply reverse charge to an invalid number β€” and the liability for the uncharged VAT lands back on you. For where this sits in the determination pipeline, see the parent guide on VAT & GST tax calculation. This page covers validating the number against VIES (the EU’s VAT Information Exchange System), caching results so you do not hammer a flaky service, the invoice wording the law requires, and what to do when VIES is down at finalization time.

The core risk is that VIES is the single source of truth for whether a number is valid, and VIES is not highly available. Treating a successful validation as permanent, or blocking checkout when VIES times out, are the two failure modes that bite in production. The right design validates once per billing period, caches the consultation (including the official consultation number VIES returns), and degrades gracefully.

Trade-offs

Strategy Latency on checkout VIES load Risk on invalid number Audit defensibility Best when
Validate every invoice, synchronous 300–2000 ms, blocks High Low Strong (fresh consult) Low volume, VIES reliable
Validate once, cache 30 days <5 ms cache hit Low Low if re-checked Strong with stored consult no. Recurring subscriptions
Trust customer claim, no VIES 0 ms None High β€” your liability None Never
Async validate, charge VAT until confirmed 0 ms blocking Low None Strong Conservative, refund on confirm

The recurring-subscription reality favours validate-once-and-cache: a B2B customer is billed monthly for years, so re-validating on every invoice is wasteful and fragile. Cache the consultation, re-check on a 30-day cadence, and store the consultation number for the audit trail.

Step-by-Step Implementation

1. Normalise and pre-check syntax

Strip spaces, uppercase, and reject obviously malformed numbers before spending a network call.

import re

VAT_FORMATS = {"DE": r"^DE\d{9}$", "FR": r"^FR[A-Z0-9]{2}\d{9}$",
               "NL": r"^NL\d{9}B\d{2}$"}  # extend per member state

def normalise_vat(raw: str) -> str:
    return re.sub(r"\s+", "", raw).upper()

def syntactically_valid(vat: str) -> bool:
    country = vat[:2]
    pattern = VAT_FORMATS.get(country)
    return bool(pattern) and re.match(pattern, vat) is not None

2. Check the cache before calling VIES

Cache keyed on the VAT number; re-validate only when the entry is older than the TTL.

from datetime import datetime, timedelta, timezone

VIES_TTL = timedelta(days=30)

def cached_validation(vat: str, store) -> dict | None:
    row = store.get(f"vies:{vat}")
    if row and datetime.now(timezone.utc) - row["checked_at"] < VIES_TTL:
        return row          # βœ… fresh cache hit, no network call
    return None             # miss or stale β†’ consult VIES

3. Consult VIES and record the result

Call the VIES SOAP endpoint and persist the verdict plus the consultation number it returns.

def consult_vies(vat: str, store) -> dict:
    country, number = vat[:2], vat[2:]
    try:
        resp = vies_client.checkVatApprox(countryCode=country, vatNumber=number)
        result = {"vat": vat, "valid": resp.valid,
                  "consultation_number": resp.requestIdentifier,  # audit proof
                  "checked_at": datetime.now(timezone.utc)}
        store.set(f"vies:{vat}", result, ttl=VIES_TTL.total_seconds())
        return result                                  # βœ… authoritative verdict
    except ViesUnavailable:
        raise                                           # ⚠️ handled in step 5

Only when the number validates and the supply is cross-border do you zero-rate and add the mandatory invoice wording.

def b2b_decision(supplier_country: str, customer_country: str,
                 validation: dict) -> dict:
    if validation["valid"] and customer_country != supplier_country:
        return {"treatment": "reverse_charge", "rate_bps": 0,
                "note": "VAT reverse charged β€” Article 196 Directive 2006/112/EC. "
                        f"Customer VAT: {validation['vat']}"}
    return {"treatment": "standard",
            "rate_bps": lookup_rate(customer_country)}  # invalid β†’ charge VAT

5. Fall back when VIES is down

Do not block finalization on a VIES outage. Charge standard VAT now and queue an async re-validation, refunding via a credit note if the number later validates β€” or, if you prefer, finalize as reverse charge against a recent cached consult and re-confirm out of band.

def validate_with_fallback(vat: str, store, queue) -> dict:
    cached = cached_validation(vat, store)
    if cached:
        return cached
    try:
        return consult_vies(vat, store)
    except ViesUnavailable:
        queue.enqueue("revalidate_vat", vat=vat)        # ⚠️ retry off critical path
        return {"vat": vat, "valid": False, "reason": "vies_unavailable"}

Verification & Testing

Mock the VIES client to assert each branch: a valid cross-border number yields reverse_charge with the legal note and the customer VAT on the invoice; an invalid number yields standard VAT; a same-country number never reverse-charges even when valid. Assert that a second validation within the TTL is served from cache with zero VIES calls, and that a stale entry triggers a fresh consult. Simulate a ViesUnavailable exception and assert finalization still completes (standard VAT charged, re-check enqueued) rather than throwing on the path. Reconcile that every reverse-charge tax_transaction carries a stored consultation number β€” a daily job asserting this catches silent validation gaps, complementing the idempotent webhook consumer pattern that prevents double determination on replay.

Gotchas & Production Pitfalls

  • VIES validates existence, not ownership. A valid number only confirms it is registered, not that it belongs to your customer. Store the consultation number as your defence, and combine with the location evidence from the EU VAT OSS/MOSS guide.
  • VIES is regularly down or rate-limited. Member-state systems go offline for maintenance and VIES simply returns unavailable for that country. Caching and a fallback path are not optional.
  • Same-country B2B is not reverse charge. Domestic B2B is charged standard VAT; reverse charge applies only cross-border within the EU. Branch on country equality before treatment.
  • Stale cache after deregistration. A customer can deregister; a 30-day cache will keep reverse-charging them. Re-validate on each billing period and on any number change.
  • Missing or wrong legal note invalidates the invoice. Reverse-charge invoices must state the customer’s VAT number and the legal basis. An invoice without it is non-compliant even if the tax treatment was correct β€” ensure it flows into invoicing & credit notes.