· Patryk Komow · 11 min read

EU VAT for a SaaS with Stripe Tax: OSS, reverse charge and VIES

Selling a subscription to someone in the EU means charging VAT at the rate of their country, unless they're a business with a valid VAT number in another member state. Stripe Tax does the arithmetic. Deciding when a business really is a business is your job, and that part needs VIES.

This is how I built it, not tax advice. The rules depend on where you're established and what you sell; check yours with an accountant.

The rules in one table

For digital services such as a SaaS subscription, sold by a business established in the EU:

CustomerVAT on the invoiceHow you report it
Consumer in your own countryYour country's rateYour normal VAT return
Consumer in another EU countryThe customer's country rate (once your cross-border EU sales pass €10,000 a year)One quarterly OSS return for all countries
Business in your own countryYour country's rateYour normal VAT return
Business in another EU country, valid VAT numberNone: reverse chargeYour return and the EU sales list
Anyone outside the EUUsually none from the EU sideCheck the customer country's own rules

If you're established outside the EU, there's no €10,000 threshold: you charge the customer's rate from the first EU consumer sale, through the non-Union OSS scheme.

Two things in that table are where SaaS billing code usually goes wrong. First, the rate depends on where the customer is, so you need their address. Second, the reverse charge row depends on a VAT number being valid, and "the customer typed something that looks like a VAT number" isn't the same thing.

Turning on Stripe Tax in Checkout

Stripe Tax works out the rate from the customer's address and tax ids, adds VAT to the payment, and prints the right wording on invoices, including the reverse charge note. In Checkout it's a few parameters:

# app/billing/service.py, inside checkout_params()
if settings.eu_mode:
    # Stripe Tax works out the VAT from the customer's address, and applies the
    # reverse charge when the customer has a (VIES-checked) VAT ID.
    params["automatic_tax"] = {"enabled": True}
    params["billing_address_collection"] = "required"
    # Save the address typed in Checkout on the customer (Stripe Tax needs it).
    # The name stays as set in the app, so a company name isn't overwritten.
    params["customer_update"] = {"address": "auto"}
    if plan.mode == "payment":
        # Subscriptions always get invoices; one-time payments only when asked.
        params["invoice_creation"] = {"enabled": True}

customer_update is the one people miss. When you pass an existing customer to Checkout (and you should, see the subscriptions tutorial), Checkout needs permission to save the address it collects onto that customer. Without it, and with no address already saved on the customer, Stripe refuses to create the session, because Stripe Tax has nothing to calculate from.

Keep the whole block behind one setting. If you only sell outside the EU, or you handle tax some other way, everything above switches off and prices are charged as they are.

Why VAT numbers don't go through Checkout

Checkout can collect tax ids itself with tax_id_collection. It's tempting: one line. Stripe checks the format and verifies EU numbers in the background. But I wanted three things it doesn't give me in one place: the check done before a number can change a price, a clear answer to show the customer while they're still on the page, and the evidence stored on my side. If a number that isn't valid ends up removing VAT, that VAT is still yours to pay.

So the app collects company details on its own page (company name, address, optional VAT number), checks the number in VIES, the EU's register of VAT numbers, and only then adds it to the Stripe customer. The customer sees the reverse charge from their next payment on. The same check also gives you the evidence the reverse charge needs: the name VIES has for the number, and when you checked.

Checking a VAT number in VIES

VIES has a free REST endpoint with no API key. Before calling it, normalise the input and reject obviously wrong formats offline, so a typo doesn't cost a network round trip:

# app/billing/vat.py
VIES_URL = "https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number"
VIES_TIMEOUT_SECONDS = 10

# ISO country code -> VAT prefix. Greece uses EL.
EU_COUNTRIES = {"AT": "AT", "BE": "BE", "DE": "DE", "GR": "EL", "PL": "PL", ...}

# Shape of the number after the prefix, per country (from the VIES documentation).
FORMATS = {
    "AT": r"U\d{8}", "BE": r"[01]\d{9}", "DE": r"\d{9}", "EL": r"\d{9}",
    "FR": r"[A-Z0-9]{2}\d{9}", "NL": r"\d{9}B\d{2}", "PL": r"\d{10}", ...
}


class VatStatus(enum.StrEnum):
    VALID = "valid"
    INVALID = "invalid"  # wrong format, or VIES says the number isn't registered
    UNAVAILABLE = "unavailable"  # VIES or the national register didn't answer


@dataclass
class VatCheck:
    status: VatStatus
    vat_id: str  # normalized, with prefix, e.g. "PL5260250995"
    name: str = ""
    address: str = ""


def normalize_vat_id(country: str, raw: str) -> str:
    """'pl 526-025-09-95' -> 'PL5260250995'. Adds the country prefix if it's missing."""
    prefix = EU_COUNTRIES.get(country.upper(), country.upper())
    value = re.sub(r"[\s.\-/]", "", raw).upper()
    if not value.startswith(prefix):
        value = prefix + value
    return value


def has_valid_format(vat_id: str) -> bool:
    prefix, number = vat_id[:2], vat_id[2:]
    pattern = FORMATS.get(prefix)
    return bool(pattern and re.fullmatch(pattern, number))

Greece is the classic trap: its ISO code is GR, but VAT numbers start with EL. Map one to the other instead of trusting the country dropdown.

Then the call itself, in a function of its own so tests can replace it:

async def ask_vies(prefix: str, number: str) -> dict:
    """The raw VIES answer. Tests replace this function."""
    async with httpx.AsyncClient(timeout=VIES_TIMEOUT_SECONDS) as client:
        response = await client.post(
            VIES_URL, json={"countryCode": prefix, "vatNumber": number}
        )
        response.raise_for_status()
        return response.json()

When VIES is down

VIES doesn't hold the data itself. It asks each country's register, and those go offline regularly, sometimes for hours. A check therefore has three outcomes, not two:

async def check_vat_id(country: str, raw: str) -> VatCheck:
    vat_id = normalize_vat_id(country, raw)
    if country.upper() not in EU_COUNTRIES or not has_valid_format(vat_id):
        return VatCheck(VatStatus.INVALID, vat_id)

    try:
        answer = await ask_vies(vat_id[:2], vat_id[2:])
    except (httpx.HTTPError, ValueError) as exc:
        logger.warning("VIES request failed: %s", exc)
        return VatCheck(VatStatus.UNAVAILABLE, vat_id)

    if answer.get("actionSucceed") is False:
        errors = [e.get("error") for e in answer.get("errorWrappers", [])]
        logger.warning("VIES could not check %s: %s", vat_id, errors)
        status = VatStatus.INVALID if "INVALID_INPUT" in errors else VatStatus.UNAVAILABLE
        return VatCheck(status, vat_id)

    if not answer.get("valid"):
        return VatCheck(VatStatus.INVALID, vat_id)
    # VIES uses "---" when a country doesn't share the name or address.
    name = answer.get("name", "").replace("---", "").strip()
    address = answer.get("address", "").replace("---", "").strip()
    return VatCheck(VatStatus.VALID, vat_id, name=name, address=address)

What you do with UNAVAILABLE matters more than the code. Treating it as invalid tells a real company their number is wrong. Treating it as valid hands out VAT-free prices with no evidence. The honest answer is the third option: save nothing, and tell the customer the register didn't answer and to try again later.

# app/billing/details_routes.py
async def _check(country: str, vat_id: str) -> tuple[vat.VatCheck | None, str | None]:
    """Returns (valid check, None) or (None, translation key of the problem)."""
    if country not in vat.EU_COUNTRIES:
        return None, "billing.vat.eu_only"
    check = await vat.check_vat_id(country, vat_id)
    if check.status == VatStatus.UNAVAILABLE:
        return None, "billing.vat.unavailable"
    if check.status == VatStatus.INVALID:
        return None, "billing.vat.invalid"
    return check, None

The same check runs live while the customer types (an HTMX request on the field, so they see "valid: ACME Sp. z o.o." before they submit) and again on save. Only the check on save counts. The live one is for the user, the one on save is for you.

Some countries don't share company names or addresses through VIES and send --- instead. Don't show that to the user as the company name.

Putting the number on the Stripe customer

Once a number is valid, save it locally with the evidence, and copy the company details and the number to the Stripe customer. Stripe Tax reads the customer's tax ids when it calculates the next payment.

# app/billing/service.py
async def save_profile(
    session: AsyncSession, user: User, details: dict, vat_check: VatCheck | None
) -> BillingProfile:
    profile = await get_profile(session, user) or BillingProfile(user_id=user.id)
    for field, value in details.items():
        setattr(profile, field, value)
    if vat_check:
        profile.vat_id = vat_check.vat_id
        profile.vies_name = vat_check.name or None
        profile.vat_verified_at = datetime.now(UTC)
    else:
        profile.vat_id = profile.vies_name = profile.vat_verified_at = None
    session.add(profile)
    await session.commit()

    customer_id = await ensure_customer(session, user)
    await gateway.update_customer(
        customer_id,
        {
            "name": profile.company_name,
            "address": {
                "line1": profile.address_line,
                "postal_code": profile.postal_code,
                "city": profile.city,
                "country": profile.country,
            },
        },
    )
    await gateway.set_customer_vat_id(customer_id, profile.vat_id)
    return profile

Stripe keeps tax ids as a list on the customer, so "set the VAT number" means: delete the EU VAT ids that don't match, and add the new one if it isn't there yet. Removing the number (a business becomes a consumer) uses the same function with None.

# app/billing/stripe_gateway.py
async def set_customer_vat_id(customer_id: str, vat_id: str | None) -> None:
    """Replaces the customer's EU VAT ID (or removes it when vat_id is None)."""
    tax_ids = client().v1.customers.tax_ids
    existing = await tax_ids.list_async(customer_id)
    for tax_id in existing.data:
        if tax_id.type == "eu_vat" and tax_id.value != vat_id:
            await tax_ids.delete_async(customer_id, tax_id.id)
    if vat_id and not any(t.type == "eu_vat" and t.value == vat_id for t in existing.data):
        await tax_ids.create_async(customer_id, params={"type": "eu_vat", "value": vat_id})

Note what happens for a business in your own country: it has a valid VAT number, it's on the customer, and Stripe Tax still charges your domestic rate, because the reverse charge only applies across borders. You don't have to special-case it.

Inclusive or exclusive prices

Every Stripe price has a tax_behavior:

Consumer-facing products usually go inclusive, because a price that changes at checkout feels like a trick. If most of your customers are businesses, exclusive is fairer to you: they reclaim the VAT anyway. Decide before you create the prices: once a price's tax_behavior is set to inclusive or exclusive it can't be changed, so switching later means new prices.

Invoices

Subscriptions always produce Stripe invoices, with your details, the customer's details and VAT number, the rate and amount per country, and the reverse charge note when it applies. For one-time payments, ask Checkout for one with invoice_creation (in the snippet above).

Keep a local copy through webhooks (invoice.finalized, invoice.paid, invoice.voided), so your billing page can list them with PDF links without calling Stripe. One API change to know about: since version 2025-03-31, the tax lives in total_taxes, not tax:

async def sync_invoice(session: AsyncSession, data: dict) -> Invoice | None:
    if data["status"] == "draft":
        return None  # no number or PDF yet
    ...
    tax = sum(t.get("amount", 0) for t in data.get("total_taxes") or [])
    issued = (data.get("status_transitions") or {}).get("finalized_at") or data["created"]
    values = {
        "number": data.get("number"),
        "status": data["status"],
        "currency": data["currency"],
        "total": data.get("total") or 0,
        "tax": tax,
        "pdf_url": data.get("invoice_pdf"),
        "hosted_url": data.get("hosted_invoice_url"),
        "issued_at": _timestamp(issued),
    }
    ...

Local rules can go further than a Stripe invoice. Poland, for example, requires B2B invoices between Polish VAT payers to go through its national e-invoicing system, KSeF, which Stripe invoices don't. If a rule like that applies to you, issue those invoices in the national system as well.

The Dashboard side

The code is half of it. In the Stripe Dashboard, in test mode first:

  1. Settings → Tax: your head office address, the default tax behavior, and your registrations: your home country, and OSS if you're registered for it. Stripe Tax only collects VAT where you've told it you're registered. With no registrations, it calculates nothing and your checkout looks fine while charging 0 VAT.
  2. Set your product's tax code to the software-as-a-service category, so Stripe picks the right rate.
  3. Settings → Business → Tax IDs: add your own VAT number, so it's printed on invoices.
  4. Settings → Billing → Invoices: invoice number prefix, footer and logo.

Then test the three cases with the test card 4242 4242 4242 4242: a consumer address in your country, a consumer address in another EU country, and a company with a valid VAT number from another EU country. Open each invoice PDF. You should see three different VAT lines, the last one with the reverse charge note.

Checklist

This VAT module ships with getpylaunch, a FastAPI SaaS starter I sell, where it switches on with a single EU_MODE setting.