Handling EU VAT OSS/MOSS for Digital Goods
You hit this the moment you sell a digital subscription to a consumer in another EU member state. Under the EU rules for electronically supplied services, VAT is due in the customer’s country at the customer’s rate — not yours — and you report it all through a single One-Stop-Shop (OSS) return instead of registering in 27 countries. The old Mini One-Stop-Shop (MOSS) was folded into the broader OSS scheme in July 2021. For the determination mechanics this builds on, see the parent guide on VAT & GST tax calculation. This page covers the three things OSS specifically demands: two non-contradictory pieces of location evidence per sale, per-country rate application, and the quarterly return aggregation.
The defining constraint is evidence. For B2C digital sales you must obtain and retain two non-contradictory pieces of evidence of the customer’s location, and you charge the rate of the member state those proofs point to. There is also a simplification: if your total pan-EU cross-border B2C digital sales stay under EUR 10,000 per year, you may charge your home-country rate instead — but once you cross it, you charge destination rates on everything.
Trade-offs
| Approach | Setup effort | Per-country rate accuracy | Evidence handling | Filing burden | Best when |
|---|---|---|---|---|---|
| Stay under EUR 10k, home rate | Minimal | N/A (home rate) | Single proof sufficient | Domestic VAT return only | Early-stage, low EU volume |
| OSS via Stripe Tax | Low | Maintained for you | Stripe captures IP + address | Stripe outputs OSS figures | Already on Stripe |
| OSS via Avalara | Medium | Maintained for you | You feed evidence | Avalara prepares returns | Multi-processor / mixed stack |
| Custom rate table + OSS aggregation | High | You maintain tax_rates |
You design evidence capture | You aggregate and file | Few products, control needed |
Crossing EUR 10,000 is the decision point: below it the home-rate simplification removes nearly all complexity; above it you must apply 27 destination rates and file an OSS return, which is when a maintained engine earns its cost.
Step-by-Step Implementation
1. Collect two non-contradictory proofs
Capture independent signals at checkout and require at least two that agree on a member state.
from dataclasses import dataclass
@dataclass
class OssEvidence:
billing_country: str | None # self-declared address
ip_country: str | None # geolocated at purchase
bin_country: str | None # card issuer country
def consumer_member_state(ev: OssEvidence) -> str:
proofs = [c for c in (ev.billing_country, ev.ip_country, ev.bin_country)
if c and is_eu(c)]
agreeing = max(set(proofs), key=proofs.count, default=None)
if agreeing and proofs.count(agreeing) >= 2:
return agreeing # ✅ two non-contradictory proofs
raise EvidenceConflict(proofs) # ✗ cannot safely determine member state
2. Apply the destination rate (or home rate under threshold)
Check the running annual cross-border total against EUR 10,000, then pick the rate.
def oss_rate_bps(member_state: str, ytd_cross_border_cents: int,
home_country: str, invoice_date: str) -> tuple[str, int]:
if ytd_cross_border_cents < 1_000_000: # EUR 10,000 in cents
return home_country, lookup_rate(home_country, invoice_date)
return member_state, lookup_rate(member_state, invoice_date) # destination rate
3. Persist evidence on the tax transaction
Store both proofs in location_evidence so the filed return is reproducible and audit-defensible.
INSERT INTO tax_transactions
(invoice_id, customer_id, jurisdiction, treatment, tax_rate_id,
taxable_base_cents, tax_amount_cents, location_evidence)
VALUES
(:invoice_id, :customer_id, :member_state, 'standard', :tax_rate_id,
:base_cents, :tax_cents,
jsonb_build_object('billing', :billing, 'ip', :ip, 'bin', :bin));
4. Aggregate the quarterly OSS return
Sum taxable base and VAT per member state for the quarter — this is the OSS filing.
SELECT jurisdiction AS member_state,
SUM(taxable_base_cents) AS net_cents,
SUM(tax_amount_cents) AS vat_cents
FROM tax_transactions
WHERE treatment = 'standard'
AND determined_at >= :quarter_start
AND determined_at < :quarter_end
GROUP BY jurisdiction
ORDER BY jurisdiction; -- one row per member state on the OSS return
Verification & Testing
Assert that a sale with only one proof, or two contradicting proofs, raises rather than guessing a member state. Drive a fixture customer in each of several member states and assert the applied rate matches the rate in force on the invoice date. Cross the EUR 10,000 boundary in a test: the invoice immediately below the threshold uses the home rate and the one above uses the destination rate. Run the aggregation query against a seeded quarter and assert each member-state subtotal equals the sum of its constituent tax_transactions. Reconcile the aggregated vat_cents against the vat_payable ledger lines for the same period — they must be equal, tying the OSS return back to the double-entry ledger posting.
Gotchas & Production Pitfalls
- The EUR 10,000 threshold is pan-EU and annual, not per country. It aggregates all cross-border B2C digital sales across every member state; teams who track it per country cross it without noticing and under-collect.
- One proof is not enough. A single billing-address country fails the evidence standard. If you only have one signal, you cannot safely apply OSS — capture IP and BIN country too.
- VPNs and travelling customers create contradictions. An IP country that disagrees with the billing and BIN countries should be down-weighted; let two of three agree rather than trusting IP alone.
- Rates change mid-quarter. A member state can adjust its VAT rate on a date inside your filing period; the time-bounded rate lookup must apply each sale’s date, or one return mixes two rates incorrectly.
- OSS is B2C only. A validated business customer is reverse charge, not an OSS sale — route them through reverse charge B2B VAT validation with VIES and exclude them from the OSS aggregation.