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. Each one’s
display_name carries the date it was 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.
inclusive defaults to true. 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} edits rate_basis_points, display_name, inclusive and active; the
country cannot change. "display_name": null removes the display name, and null on any of the
other three is refused with a 400 naming the field. Edit in place only to fix a mistake, and create a new rate when a country’s
VAT actually changes. {"active": false} retires a rate, which is how you stop charging VAT in a
country, and {"active": true} brings a retired one back, retiring whichever rate holds that
country now. 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. GET /v1/tax_rates lists retired rates too. Tax rate changes emit no webhook
event; they are recorded in the audit log.
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 160 is backed out of it, net 839. |
| 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 a new price, which hands the decision to the tax rate
for the buyer’s country. It can be set once on an existing price, while it is still
"unspecified", with POST /v1/prices/{id}; after that it is fixed. See
Products and prices.
When no rate applies to the buyer, tax_behavior has nothing to decide: the charge is the price’s
amount with no VAT, whether the price is inclusive or exclusive.
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. When the customer has no country yet, it is
stored 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. The customer needs a country for
that: send country_code in the same call if they have none, or the number is stored as
unverifiable. 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.
Sending vat_number: null clears the registration. Every accepted call emits customer.updated.
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.
Under reverse charge the customer pays the price’s amount with nothing added and nothing backed
out, whatever its tax_behavior. An inclusive €99 price charges a reverse-charged business €99,
all of it net.
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 carries your country, your VAT id and the name and address printed on your
invoices (legal_name, address_line1, address_line2, postal_code, city,
registration_number). BillKit’s own invoices to you are issued to legal_name and your
address, and are not issued until legal_name, address_line1 and city are set. Three fields
decide VAT, 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. 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 a live-mode key: your billing identity is one setting for both modes and live
charges read it, so a test key is refused with 403 mode_mismatch. Set it from the console, or with
your live key. 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. Leave it out 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"}'You set it once. BillKit checks the shape of a new number and then asks the EU VIES register whether it is valid for your country:
- If VIES says it is not, the request fails with
400andreason: "vat_not_recognised", and nothing is stored. Almost always this is a typo, or a country that does not match the number. - If VIES cannot be reached, the number is stored and checked again when BillKit next bills you. VIES goes down, and a new registration can take days to appear there.
Once stored, the number cannot be changed or removed through the API or the console. Sending a
different value, or null, returns 400 with reason: "vat_id_locked". Sending the same number
again is accepted, so you can keep sending your whole profile. To change or remove it, for a
re-registration or a deregistration, contact support@billkit.eu.
country_code is always required. The address fields are optional: omitted is left alone, null
clears.
The shape rule is a two-letter country code followed by 2 to 12 letters or digits, spaced with
dots, dashes, slashes or spaces if you like. The 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.
If BillKit refuses to bill you because of your VAT id, the refusal says why. When no number is stored, add it here and retry the invoice. When a stored number is rejected, for example because the registration has lapsed, contact support to replace it.
Setting or changing 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 the old and the new
number, on its own entry separate from country changes. A change made by support is recorded there
too.
What lands on the invoice
| Field | Meaning |
|---|---|
| subtotal_cents | Net, before VAT and before any discount. |
| tax_cents | VAT charged. Zero under reverse charge. |
| total_cents | The amount charged, VAT included: subtotal_cents less discount_cents plus tax_cents. |
| 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.
A renewal is priced once per period, when BillKit first attempts it. Until then the tax follows the customer and your rates as they are, so a VAT number validated or a country corrected before the renewal date reaches that renewal. From the first attempt on the amount is fixed: if the charge fails, every retry charges what the first attempt did, and a change made in the meantime reaches the next period. The invoice is issued when the renewal is paid.
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 an unbroken number sequence is easier to reconcile.
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 get their invoice by email, with a download link. The link downloads
the PDF directly and works for 90 days, which is what a business buyer needs to reclaim the VAT.
The hosted portal is scoped to a subscription, so there is no portal for a one-off buyer to open.
To offer the document anywhere else, serve it 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