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.
It helps to be precise about what reverse charge actually moves. The supply is still taxable — the customer’s member state still levies VAT on it — but the obligation to declare and pay that VAT shifts from you, the supplier, to the customer, who records both an output entry and a matching input entry in their own return. For a fully taxable business those two entries net to zero, which is why reverse charge is largely a cash-flow and paperwork mechanism rather than a rate change. Your invoice shows a rate_bps of 0 and a total in the same integer minor units as the net, but you are not treating the supply as exempt or out of scope; you are documenting that liability has passed under Article 196 of Directive 2006/112/EC. That distinction matters when the same invoice_id later appears in your EC Sales List (recapitulative statement), where every reverse-charged line must be reported against the customer’s validated VAT number and the totals must reconcile to the zero-rated cross-border supplies in your ledger.
Trade-offs
The strategies trade checkout latency against VIES load and the risk of reverse-charging an invalid number. Validating every invoice is fresh but slow and fragile; validate-once-and-cache is the recurring-subscription default; charging VAT until confirmed is the conservative option. The map ranks them.
| 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.
Where to pin the validation moment
The strategies above differ less in code than in when they run relative to invoice finalization. The safest anchor is the moment a subscription_id moves to active or its billing anchor date advances, not the moment the customer types the number into a form. Validating at checkout is tempting because that is when the human is present to fix a typo, but a number that was valid at signup can lapse before the third renewal, and a checkout-time verdict tells you nothing about the state of the number two years later. A practical split is to validate synchronously at checkout purely for the user-facing feedback (“we could not verify this VAT number”), then re-validate asynchronously against the anchor date whenever an invoice is about to be drafted. The verdict written to the invoice must come from the anchor-date consultation, because that is the date the tax point attaches to and the date an auditor will reconstruct.
The 30-day TTL is a compromise, not a rule handed down by the tax authority. A shorter TTL — say seven days — narrows the window in which a deregistered customer keeps getting zero-rated, at the cost of more VIES traffic and more exposure to its outages. A longer TTL of 90 days cuts calls further but widens the deregistration gap. Because the failure mode of a stale positive is that you owe the uncharged VAT, tie the TTL to how much unbilled exposure a single customer represents: a customer paying 4900 minor units a month can tolerate a longer window than one paying 4900000. Where a customer’s contract value is large, force a fresh consult on every billing period regardless of cache age.
Step-by-Step Implementation
The five steps front-load cheap checks and back-load graceful degradation: normalize syntax, hit the cache, consult VIES on a miss, apply reverse charge on a valid cross-border result, and fall back when VIES is down. The flow shows the cache-then-consult-then-fallback path.
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
The syntactic pre-check earns its keep by turning a class of errors into a fast local rejection instead of a round trip. VIES returns INVALID_INPUT for a malformed number, but only after the SOAP call has crossed the network and waited on the member state, so a regex that rejects a nine-digit German number carrying ten digits saves both the latency and a wasted slot against whatever informal rate limit that country’s node enforces. Keep the per-country patterns in one table and treat an unknown country prefix as a hard reject rather than a pass-through: a number whose first two characters are not an EU member-state code cannot be reverse-charged under the intra-community rules at all, so there is nothing for VIES to confirm. Do not attempt to validate the check digits yourself beyond the coarse length and character-class rules — the national check-digit algorithms differ per country and drift, and a false negative there blocks a legitimate customer for no gain, since VIES is the authority anyway.
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
The choice between the two VIES operations is deliberate. checkVat returns only a boolean validity flag; checkVatApprox additionally accepts the requester’s own country and VAT number and, when both are supplied, returns a requestIdentifier — the consultation number that is your evidence a check happened at a given time. Always call the approximate variant and always persist that requestIdentifier, even though the trader-name and address fields it can return are optional and frequently blank. Store the raw verdict alongside a checked_at timestamp in UTC and the exact country and number you sent, because a dispute months later turns on proving what you asked and what VIES answered, not on your derived treatment. Keep the write idempotent under an idempotency_key derived from the VAT number and the day, so a retried consult after a transient timeout overwrites rather than duplicates the row.
4. Apply reverse charge with the legal note
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
The tests mock VIES to assert each branch, prove the cache saves calls, and confirm a VIES outage never blocks finalization. The panel lists the four cases before the detail.
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.
Do not stop at the happy branches; the branches that cost money are the transitions. Write a test that seeds a fresh positive cache entry, advances a clock past the TTL, and asserts the next draft re-consults rather than reusing the stale positive — this is the deregistration guard, and a frozen datetime.now is the only way to exercise it deterministically. Add a test where VIES returns a positive result but the request identifier is empty, and assert the code still records something falsifiable rather than silently dropping the audit field; an empty requestIdentifier from a member state that does not populate it is legitimate, but a None where you expected a string usually signals a client wiring bug. Cover the case where the customer edits their VAT number mid-subscription: the cache key changes, the old positive must not leak onto the new number, and the next invoice must consult afresh. Finally, assert on the exact invoice text, not just the treatment code, because the legal note is itself a compliance artifact — a test that only checks rate_bps == 0 passes happily on an invoice that a tax authority would reject for missing the Article 196 citation.
Property-based tests pay off for the normaliser. Generate strings with random interior whitespace, mixed case, and non-breaking spaces pasted from a customer’s accounting system, and assert normalise_vat produces a stable canonical form that round-trips through the cache key unchanged. A single stray non-breaking space (U+00A0) that your \s+ regex does or does not catch is the difference between a cache hit and a spurious second consult under load, and it is the kind of defect that never appears in a hand-written fixture.
Gotchas & Production Pitfalls
The pitfalls are about what VIES does and doesn’t guarantee, its availability, and the invoice wording. The map groups them so each fix is one rule.
- 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.
Two subtler traps sit behind the obvious five. The first is confusing your own validity with the customer’s: a supplier missing a valid VAT registration in the customer’s terms, or supplying from a country you are not registered in, changes the analysis entirely, and VIES has nothing to say about your side. The checkVatApprox call takes your requester country and number precisely so the consultation is attributable to you; keep those in configuration and fail loudly if they are unset rather than sending blanks. The second is the retroactive correction. When a queued re-check finally reaches VIES and flips a fallback-charged invoice to reverse charge, you cannot silently rewrite the finalized invoice_id; you issue a credit note against it and re-invoice at 0% with the legal note, so the correction is itself an auditable pair of documents.
Finally, watch the boundary between a genuine INVALID verdict and a SERVICE_UNAVAILABLE fault. Collapsing both into “not valid” is the most common bug in a first implementation: an unavailable member-state node means you do not know, and treating “do not know” as “invalid” charges VAT you may not owe and annoys legitimate customers, while treating it as “valid” exposes you to the liability. Map the SOAP fault strings explicitly — MS_UNAVAILABLE, TIMEOUT, and SERVICE_UNAVAILABLE route to the fallback path; only an authoritative negative from a reachable node is a real INVALID.
Frequently Asked Questions
Should a customer-supplied identifier be trusted without validation? No. Applying reverse charge on an unvalidated identifier leaves you liable for the tax that should have been charged, and the customer has no incentive to correct it.
What should happen when validation is unavailable? Charge tax and record why, then re-validate later and issue a credit note if the identifier proves valid. Defaulting to reverse charge because a service was down is the expensive direction to fail.
How often should identifiers be revalidated? Periodically for ongoing subscriptions, since registrations are cancelled. An annual revalidation, plus one at renewal, catches most cases.
Does the validation response need to be stored? Yes, with the timestamp and the consultation reference where the service provides one. It is the evidence that the reverse charge was applied on a reasonable basis.