Charge and record EU VAT
Every EU rate is seeded for you. Decide net or gross, validate VAT numbers, and get reverse charge right.
What BillKit does, and what it does not
BillKit computes VAT on every charge, records it on the payment and the invoice, and applies B2B reverse charge when a customer’s VAT number checks out. It stores the country the tax was charged for, so the figures you need for an OSS return are all present and queryable.
New accounts start with the standard VAT rate of every EU member state already seeded and active, in both test and live mode. That is the rate that applies to B2C digital services sold cross-border under the OSS scheme, so most sellers can charge correctly without configuring anything. Only EU countries are seeded: a rate for a country outside the EU would assert a registration you may not hold.
What BillKit does not do is file anything, produce returns, apply nexus logic, or handle US sales tax at any level. The seeded rates are a starting point, not a maintained service. They are stamped with the date they were verified, and BillKit does not update them when a member state changes its rate. Keeping them current is yours, and replacing one is a single call (see below).
Two cases where you should review the seeded rates before taking money. If you are under the cross-border micro-business threshold, you may be required to charge your own country’s rate rather than the customer’s, so the seeded destination rates would be wrong for you. And if you are not registered in a country, you may prefer no rate at all to an active one.
Tax resolution is by country only. There are no product tax codes, no state or postcode handling, and no address validation. If your catalogue mixes standard-rated and reduced-rated goods in one country, BillKit cannot express that.
Replace a rate
curl https://api.billkit.eu/v1/tax_rates \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"country_code": "NL",
"rate_basis_points": 2100,
"display_name": "Netherlands VAT",
"inclusive": true
}' rate_basis_points is hundredths of a percent, so 2100 is 21%. The range is 0 to 10000.
One active rate per country. Posting a second rate for the same country deactivates the first and keeps it, so historical invoices still reference the rate that actually applied when they were issued.
POST /v1/tax_rates/{id} updates, and {"active": false} on the same route retires a rate,
which is how you stop charging VAT in a country. There is no delete: an invoice records the
percentage it charged, and you have to be able to point at the rate that produced it, so a
retired rate stays readable with active: false. Neither emits a webhook event; changes are
recorded in the audit log instead, because a rate change is something a human did, not
something your application needs to react to.
Net or gross
tax_behavior decides whether the amount you set on a price is what the customer pays
(inclusive) or what they pay before VAT is added (exclusive).
| Behaviour | A price of 999 at 19% |
|---|---|
| inclusive | The customer pays 999. VAT of 159 is backed out of it, net 840. |
| exclusive | The customer pays 1189. 999 is the net, VAT of 190 is added. |
Four sources are consulted in order, and the first with an opinion wins. There is no merging.
| # | Source |
|---|---|
| 1 | The request. Only one-off payments accept a tax_behavior; a subscription checkout does not, because the same number also sets the recurring amount and a per-session override would leave renewals disagreeing with the session that started them. |
| 2 | price.tax_behavior. "unspecified" means no opinion and defers. |
| 3 | tax_rate.inclusive for the customer’s country. |
| 4 | Inclusive. |
tax_behavior defaults to "unspecified" on new prices. That is deliberate rather than lazy:
an opinionated default would silently re-price every price created before the field existed.
The seeded EU rates are all inclusive: true, so an "unspecified" price charges exactly its
amount_cents to an EU buyer and the VAT is taken out of that. Replace a rate with
inclusive: false and the same price starts adding VAT on top instead.
Set it explicitly on prices you advertise. Under “unspecified”, what the customer is charged
follows whatever the tax rate for their country says, which is a setting you can change later and in another
place. Setting inclusive or exclusive on the price is what guarantees the number on
your pricing page is the number on the invoice.
The customer’s country
Everything above hangs off customer.country_code. With no country on the record, no rate can be
matched and the charge carries no VAT at all. With a country but no active rate for it, the tax
is zero but the country is still recorded on the invoice.
BillKit fills this in for you when the buyer goes through the embedded checkout element, which asks for their country before the payment so the first charge is taxed correctly. The element will not let the payment through until that question is answered. An unanswered country means no VAT is charged, and you carry that.
To keep it one click rather than a search, the select is prefilled from the buyer’s IP address when nothing better is known. That is a starting position, not an answer: the control stays visible and editable, so a buyer on a VPN or away from home can correct it, and what they settle on is what gets stored. The lookup runs against a database inside BillKit, so the buyer’s address is never sent to a geolocation service.
A country you set yourself always wins over one BillKit observed, and an observed one is only ever written when the field is empty.
Hosted checkout is the case to watch. BillKit never gets to ask there, so the country arrives from
the settled payment: what Mollie detected, the country the card was issued in, or a single-country
method such as iDEAL. All three land after the charge, which means a first charge for a customer
with no country is untaxed, and only the renewals after it are taxed. If you already know the
country, pass country on POST /v1/checkout/sessions and it is stored on the customer before the
charge is priced.
You can set it at creation, or with POST /v1/customers/{id}. It also filters the payment methods
offered at checkout, so it is worth collecting either way.
curl https://api.billkit.eu/v1/customers/cus_Ja7Ye2vMq9xN4TdWpr \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"country_code": "DE"}' Reverse charge
A cross-border EU business customer with a valid VAT number is charged zero VAT and accounts for
it themselves. BillKit applies this automatically once three things are true: the customer has a
vat_number, VIES confirmed it, and their country differs from yours.
“Yours” means the country registered on your BillKit account, the one you picked when you signed up. You are the merchant of record for your own sales, so the comparison has to be against your own VAT registration and nobody else’s. A sale to a business in your own country is a domestic supply and you charge your normal rate on it, even when the VAT number is perfectly valid. Only the cross-border case is exempt.
Check the country on your own account before you rely on any of this. Your registered country below covers how to read it and how to change it.
curl https://api.billkit.eu/v1/customers/cus_Ja7Ye2vMq9xN4TdWpr/vat_number \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"vat_number": "DE123456789", "country_code": "DE"}' The format is checked before anything else: a two-letter country code followed by 2 to 12 letters
or digits. ., -, / and spaces are allowed and ignored, so NL8123.45.678.B01 and
NL812345678B01 are the same number. A value that cannot be a registration number is a 400 and
never reaches VIES, which is rate-limited and has nothing to say about a typo. The commonest
mistake is sending the digits without the country code; VIES takes the country as a separate
field, so that would come back as a flat rejection of a number that is actually fine.
What passes the format check is then validated against VIES on every write. Three outcomes:
| Outcome | Response | Tax effect |
|---|---|---|
| valid | 200, vat_number_validated: true | Reverse charge applies from now on. |
| invalid | 400 parameter_invalid, reason: "vat_not_recognised" | None, and nothing is written. The customer keeps whatever registration they had before. |
| unverifiable | 200 | None. The number is stored. VIES was down, the member state did not answer, or the country is outside the register. The next write re-checks. |
That last row covers non-EU registrations too. A UK, Swiss or Norwegian VAT number is a real
number VIES has no answer for, so it is stored as unverifiable rather than refused: losing a
true fact because we cannot check it would be the worse outcome. Reverse charge stays off, which
is correct anyway, since it only applies to intra-EU B2B supplies.
A valid result is cached for 24 hours, so re-sending the same number and country skips the
round trip. Changing either forces a re-check, and invalid or unverifiable always re-check.
Only valid triggers reverse charge. A number BillKit could not confirm is treated as no number,
which over-collects rather than under-collects. Over-collecting is recoverable; under-collecting
is a liability you discover at audit.
There is no manual override. No flag marks a number valid without VIES agreeing. A single API call that turns off VAT for a customer, with no third party attesting to it, is not something a billing system should make possible.
Reverse charge only fires when an active rate exists for the customer’s country. With no rate
configured the charge is untaxed anyway, but the invoice will not be flagged
tax_reverse_charged, which matters if you rely on that flag for reporting. Seed a rate for every
country you sell into, including ones where you expect mostly B2B.
BillKit never refuses a sale over tax. A missing VAT number charges the customer-country rate rather than blocking checkout: you are the merchant of record, and that call is yours.
Your registered country
Every reverse-charge decision on your account is made against this one value, so a wrong one either under-collects VAT you then owe yourself, or charges the wrong country’s VAT on a supply that should have been exempt. Check it before you rely on the exemption.
It is in the console under Settings → Tenant, or over the API:
curl https://api.billkit.eu/v1/tenant/billing_profile \
-H "Authorization: Bearer $BILLKIT_API_KEY" The response has three fields, and the difference between the first two matters:
| Field | What it means |
|---|---|
| country_code | The country stored on your account. null means nobody has ever set one. |
| effective_country_code | The country reverse charge is actually decided against right now. Falls back to a deployment default when nothing is stored. |
| vat_id | Your own VAT registration. null when you have none. There is no fallback for this one, because nothing can stand in for a registration number. |
If the two disagree, your account has no country of its own and is inheriting whatever the deployment is configured with. That is the state accounts created before self-serve sign-up are in. Set your own:
curl https://api.billkit.eu/v1/tenant/billing_profile \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"country_code": "EE"}' The code is ISO 3166-1 alpha-2 and is stored upper-case. The write needs the tenant:write
scope, and it is recorded in your audit log with both the old and the new country, so a later
question about why one month’s invoices reverse-charged and the next month’s did not has an
answer.
Changing this affects future charges only. Tax is worked out before the money moves and written onto the payment, and the invoice copies it from there. Correcting your country does not restate an invoice you have already issued, and it should not: those documents recorded the treatment that was applied at the time. Anything already wrong has to be fixed the way any other billing error is, with a credit note.
Your VAT id
This is your own registration, not your customers’. It decides how BillKit bills you, not how you bill them.
If your account is in the EU but not in the country BillKit itself is registered in, your BillKit invoices are reverse-charged against this number. Without a number VIES accepts, BillKit will not collect from you at all. It refuses rather than zero-rate a cross-border supply to a business it cannot confirm is taxable, so a wrong number means a payment that keeps failing.
It sits on the same resource as your country, and it is optional. Send null if you are not
registered:
curl https://api.billkit.eu/v1/tenant/billing_profile \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"country_code": "EE", "vat_id": "EE101234567"}' Leave the field out entirely and your stored number is left alone, so you can correct your country
without resending it. country_code is always required.
BillKit checks the shape of the number, not whether it exists. It has to be a two-letter country code followed by 2 to 12 letters or digits, and you may space it with dots, dashes or spaces the way your registration certificate prints it. The real check happens at VIES when BillKit next bills you. That split is deliberate: VIES goes down, and a new registration can take days to appear there, so a route that refused your number on VIES’s say-so would leave you unable to even record the right one.
The country prefix is not compared against your country, because the two legitimately differ. Greece
is the plain case: it registers VAT numbers as EL while the country code is GR.
Correcting a rejected number unblocks you straight away. BillKit caches the VIES result against the exact number it checked. Save a different number and the old verdict no longer applies to it, so the next attempt asks VIES again rather than replaying the rejection. Retry the invoice after you save.
Changing or clearing your VAT id is forward-only, the same as your country. It changes what you are charged next time. An invoice you have already paid keeps the number that was printed on it.
The write needs the tenant:write scope and is recorded in your audit log with both the old and the
new number, on its own entry separate from country changes.
What lands on the invoice
| Field | Meaning |
|---|---|
| subtotal_cents | Net, before VAT. |
| tax_cents | VAT charged. Zero under reverse charge. |
| total_cents | What the customer paid. |
| tax_country_code | The country the rate was resolved for. This is the OSS reporting key. |
| tax_reverse_charged | True when the B2B exemption applied. |
| tax_rate_basis_points | Per line item, so a rate change later does not rewrite history. |
The buyer’s name, email, VAT number and country are copied onto the charge when the tax is worked out, and the invoice copies them from there. Nothing re-reads the customer record afterwards, so renaming a customer or correcting their VAT number does not restate a document you have already issued. That is the behaviour you want from an invoice, and it is also why a correction is a credit note rather than an edit.
GET /v1/invoices/{id}/pdf renders the document, and GET /v1/tenant/export streams the whole
account for a period-end reconciliation. The export needs the data:export scope.
One-off sales
A one-off charge is taxed by exactly the rules above: the rate is resolved from the customer’s
country, and a validated non-domestic EU business is reverse-charged. The one thing specific to
this path is tax_behavior on the request, which says whether the amount you send is net or
gross. A subscription takes that from its price instead, because the same number sets the
recurring amount and a per-request override would leave renewals disagreeing with the charge that
started them.
Settled one-off sales are invoiced, in the same numbered series as your subscription charges, and refunding one issues a credit note against that invoice. A partial refund credits its share of the VAT rather than all of it or none of it, so the credit notes against a sale add up to the tax the sale charged. Every settled sale gets an invoice, not only the reverse-charged ones: a domestic business buyer needs the document to reclaim the VAT they paid you, and a numbered series with gaps in it is harder to reconcile than one without.
The invoice carries one_shot_payment_id where a subscription invoice carries payment_id. On a
paid invoice exactly one of the two is set, so that is how you tie a document back to the money.
Both are null on a metered invoice that is still open, because nothing has been charged for it
yet.
Your one-off buyers cannot fetch their own invoices. The hosted portal is scoped to a
subscription, so there is nothing for a one-off buyer to sign in to. BillKit emails them that
the invoice exists and leaves out the download link rather than sending one that goes nowhere.
Serve the document yourself from GET /v1/invoices/{id}/pdf.
Proration and tax
A mid-cycle plan change prorates the net amounts and applies VAT to the result, at today’s rate for the customer’s current country. That is the same thing Stripe does with separate credit and charge line items, collapsed into one figure.
It matters when the two prices disagree on tax_behavior. Prorating gross figures would credit a
VAT-bearing amount against an ex-VAT one and charge the customer a number that is neither.
Rounding
Half-up, on integer cents, computed before the charge is sent to Mollie. net + tax always equals
gross exactly, so a reconciliation never turns up a stray cent.
BillKit