Handling European VAT with Stripe

Handling European VAT with Stripe

Mehdi Abaakouk

You might have never noticed our accent, but Mergify is registered in the country where cheese is king: France. This has a myriad of implications and a pretty famous one among our peers selling digital services: companies registered in the European Union have to comply with the value-added tax for all customers living in Europe.

This has been quite a nightmare for us since the beginning. We decided early on to outsource this. So far, we have partnered with GitHub and Paddle to act as merchants of record on our behalf. That means they would be responsible for handling the taxes and invoicing for us.

Recently, however, we decided to change the way we handle subscriptions to improve our user experience. That meant replacing Paddle by Stripe. Lucky for us, Stripe evolved in recent times and released new APIs to handle VAT and Taxes to meet the Strong Customer Authentication (SCA) EU directive: this was what we missed to implement our own VAT handling.

Stripe provides a complete documentation about Tax Rates

Here we would like to share with our European fellows how we dealt with that.

๐Ÿ“ The Rules

To implement and follow the EU rules, we need to know two things when invoicing:

  1. Is the customer a registered business with a VAT number?
  2. In which country does the customer reside?

If the customer has a registered VAT number, we don't need to add any tax. If not, we need to apply the tax rate of its country of residence, collect the tax, and give it back to the EU Mini One Stop Shop.

๐Ÿค– Automation

Valerian Saliou from Crisp implemented a complete solution in his NodeJS-based project. However, it does many things that are now handled natively by Stripe. Furthermore, as we're a Python shop, we were not able to leverage this library directly. Nevertheless, it inspired us to build our own solution that:

  1. Creates and keeps in sync VAT tax rates of all EU Countries.
  2. Attaches the correct tax rate to each customer.

This is all that is now needed to get the right taxes invoiced to your customer.

๐Ÿ“ˆ Preparing the Stripe Tax Rates Database

We use the VAT rates list maintained by PwC as the source of truth. We download the file, and convert the Excel file into a consumable JSON file and import it into Stripe.

The JSON file looks like:

    "BE": {"type": "vat", "rate": 0.21},
    "FR": {"type": "vat", "rate": 0.20},
    "IT": {"type": "vat", "rate": 0.22},

Once this fils is ready, we use it to feed Stripe tax database. The code looks like:

# Load the JSON file with the latest version of the tax rate
expected_tax_rates = json.loads("expected_tax_rates.json")
# List the tax rates currently loaded in Stripe
current_tax_rates = stripe.TaxRate.list(active=True).auto_paging_iter()

# Compare tax rates:
# - archive the retired ones
# - create the ones missing
tax_rates_to_archive, tax_rates_to_create = compare_tax_rates(
    current_tax_rates, expected_tax_rates

for tax in tax_rates_to_archive:
    stripe.TaxRate.modify(tax.id, active=False)
for tax in tax_rates_to_create:
        description=f"VAT {tax.jurisdiction}",
        metadata={"updated_at": now.isoformat()},

We also need to update existing subscription with those new tax rates. Our current Stripe setup is quite simple. A customer only has one subscription and one EU VAT Tax ID or EU VAT Tax Rate. For each customer, we have to check if they need to pay the VAT and apply the correct tax rate โ€” a customer who provides an EU VAT Tax ID must not pay the VAT.

for customer in stripe.Customer.list(
    expand=["invoice_settings.default_payment_method"], ).auto_paging_iter():
    jurisdiction = customer.invoice_settings.default_payment_method.billing_details.address.country
    if jurisdiction not in EU_COUNTRIES:
    # Customer have a valid EU VAT identification number 
    if customer.tax_ids.data or :
        # Removing tax rates
        stripe.Subscription.modify(subscription.id, default_tax_rates="")
        tax_rate_id = get_tax_rate_id_for(jurisdiction)
        stripe.Subscription.modify(subscription.id, default_tax_rates=[tax_rate_id])

That's it โ€” now all that is left is to handle new customers and update to existing customers.

๐Ÿงพ Attach Tax Rates to New Customer Subscriptions

Once all EU tax rates are stored in Stripe, we also need to make sure we apply the correct tax rates to customers whenever:

  • they create a new subscription
  • they update their billing details
  • they add or remove an EU VAT identification number

When any of this action is executed, we update the tax id by comparing the country filled by the customer to the list of taxes we store in Stripe.

def set_taxes(customer_id):
    customer = stripe.Customer.retrieve(
        customer_id, expand=["invoice_settings.default_payment_method"],
    subscription = customer.subscriptions.data[0]

    set_taxes = True
    if customer.tax_ids:
        # If the customer has a EU VAT number, don't set any tax rate
        if customer.tax_ids.data[0].type == "eu_vat":
            set_taxes = False
                "Unexpected subscription.customer.tax_ids",

    # The doc say that:
    # `Pass an empty string to remove previously-defined tax rates.`
    new_tax_rates = ""
    current_tax_rates = [tax.id for tax in subscription.default_tax_rates]
    # Returns the country based on the registered payment method
    country = find_country(subscription, customer)
    if set_taxes and country:
        for tax in stripe.TaxRate.list().auto_paging_iter():
            if tax.jurisdiction == country:
                new_tax_rates = [tax.id]

    if new_tax_rates != current_tax_rates:
        stripe.Subscription.modify(subscription.id, default_tax_rates=new_tax_rates)

With that running, we're sure that every customer has always its tax rate up-to-date.


At Mergify, we are now pleased to handle the TVA and invoices our self. It turned out to be smoother than we thought: we only manage one kind of subscription and are incorporated in France. Things could get more convoluted for companies selling different products from a different country.

We think that it's likely Stripe will continue evolving its API to ease tax handling. Most of the glue code we wrote so far might become useless in the future if they decide to provide a mechanism to automatically apply the stored tax rates.

Nevertheless, we'd like to thank Stripe for its current awesome API and use-case documentation. That helped us a lot! ๐ŸคŸ