Customers
A customer is the party you bill. The record is deliberately thin, because the payment credentials that matter live at Mollie, not here.
The object
| Field | Notes |
|---|---|
| idstring | cus_… |
| emailstring | null | Optional. Where invoices, receipts, trial reminders and dunning notices go. A customer without one gets no email from BillKit. |
| namestring | null | Up to 255 characters. Shown on invoices and in the portal. |
| country_codestring | null | ISO 3166-1 alpha-2. This is what selects the VAT rate and filters the payment methods offered at checkout. BillKit fills it in from the buyer’s own answer at checkout when you leave it empty, and never overwrites one you set. See Charge and record EU VAT. |
| vat_numberstring | null | Set through its own endpoint, not on create or update. |
| vat_number_validatedboolean | True only when VIES confirmed the number. |
| metadataobject | Up to 50 keys, keys up to 40 characters, values up to 500. Echoed on the object and on its events. |
| test_clockstring | null | Test mode only, set at create time. See Test mode. |
| livemodeboolean | |
| createdinteger | Unix seconds. |
Every field is optional on create, including email. Update with POST /v1/customers/{id}, not
PATCH. Request bodies reject unknown fields with a 422, so a typo like ammount_cents fails
loudly instead of being ignored.
curl https://api.billkit.eu/v1/customers \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ada@example.com",
"name": "Ada Lovelace",
"country_code": "NL",
"metadata": {"workspace_id": "ws_4471"}
}' Use metadata to carry your own identifier. It comes back on the customer and on every
customer.* event, which saves you a lookup table on the receiving end.
BillKit never deduplicates by email. Two calls with the same address give you two customers. If you want
one record per person, look them up yourself before creating, or keep your own id in metadata and
key on that.
Payment methods are not on the customer
There is no default_payment_method field, and no endpoint to attach a card. A usable payment
credential in BillKit is a Mollie mandate, and a mandate only exists because a real first payment
went through Mollie. So the flow is always: checkout session, customer pays, mandate stored
against the resulting subscription.
Customers who already have a mandate can manage their saved methods from the customer portal or
the Payment Method Element, which read GET /v1/checkout/payment-methods. That surface is
authenticated by the customer’s own short-lived token, not by your secret key.
VAT numbers
POST /v1/customers/{id}/vat_number takes vat_number and an optional country_code, and runs
the number through VIES server-side. There is no way to mark a number valid by hand, which is
deliberate: a manual override is a one-call way to silently stop charging VAT to a customer, and
that is not something that should be possible without a trace.
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": "NL123456789B01", "country_code": "NL"}' The shape is checked first: a two-letter country code followed by 2 to 12 letters or digits, with
., -, / and spaces allowed and ignored. A value that cannot be a registration number is a
400 and is never sent to VIES. Send the country code as part of the number, so NL123456789B01
and not 123456789B01, because VIES takes the country as its own field and answers a bare body with
a flat rejection.
Three outcomes after that, all recorded in the audit log:
| Outcome | What happens |
|---|---|
| valid | VIES recognised the number. vat_number_validated becomes true, and a cross-border EU customer is reverse-charged from then on. |
| invalid | The call returns 400 parameter_invalid with reason: "vat_not_recognised", and nothing is written, so the customer keeps the registration they had before. |
| unverifiable | VIES was unreachable, the member state did not answer, or the country is outside the register it covers (a UK or Swiss number, say). The number is stored, VAT keeps being charged, and the next write re-checks. |
A valid result is cached for 24 hours, so re-sending the same number and country is a no-op.
Changing either forces a fresh check, and invalid or unverifiable always re-checks. Setting
vat_number to null clears the number and the validation state.
Full behaviour, including how reverse charge affects the invoice, is in Charge and record EU VAT correctly.
Abandoned checkouts
A checkout that captures an email creates the customer before the payment, not after. So someone who opens your checkout, types their address and then closes the tab leaves a customer row behind. This is not a bug, and it is not something you can switch off: the customer has to exist before the charge for the payment to be attributable to anyone.
Those rows carry provisional: true. The flag clears the moment a payment settles, and never
comes back.
GET /v1/customers returns both kinds by default. Filter to get one or the other:
# People who have actually paid you.
curl -s https://api.billkit.eu/v1/customers?provisional=false \
-H "Authorization: Bearer $BILLKIT_API_KEY"
# Abandoned checkouts: the cart-recovery worklist.
curl -s https://api.billkit.eu/v1/customers?provisional=true \
-H "Authorization: Bearer $BILLKIT_API_KEY"
The dashboard applies the same split: All shows customers, and Abandoned checkouts is its own tab.
Two things to know before you build on this. Provisional customers are deleted after a retention window (30 days by default), along with their unpaid checkout sessions, so treat them as a recovery window rather than a durable record. If you want them permanently, copy what you need into your own system. And BillKit never deduplicates by email, so one person who abandons three checkouts is three provisional rows; deduplicate on your side before emailing them.
Nothing is ever deleted once it converts, regardless of age.
Deleting versus purging
Two different operations, for two different reasons.
DELETE /v1/customers/{id} removes the customer from the API. GET /v1/customers/{id} returns
404, they stop appearing in GET /v1/customers, and customer.deleted fires. The response is
{"id": "cus_...", "object": "customer", "deleted": true}, not the customer. Their payments,
invoices and refunds are untouched, and so is their personal data, so this is not an erasure.
It is refused while the customer has a subscription that can still charge them, which is any
subscription in active, trialing, past_due or incomplete. A trial counts, because it turns
into a real charge on its own. The refusal is a 400 parameter_invalid with
reason: "customer_has_active_subscription". Cancel the subscription first. Nothing is cancelled
for you, because a delete call should never end someone’s service on its own. A customer who is
still being billed but can no longer be seen is the worst of both.
POST /v1/customers/{id}/purge with {"confirmed": true} is the GDPR erasure. Email is
overwritten with an unusable placeholder, name and metadata are cleared, the VAT registration
(vat_number and its validation state) is removed, and purged_at is stamped. The row survives so
invoices and payments keep their foreign keys, which is what tax retention law requires. The
customer.deleted event carries a snapshot taken before redaction, so your own systems can still
match it to their records.
The same call also redacts that customer from your event log. event.data is a snapshot of the
whole object at the moment it was emitted, so without this their email and VAT number would stay
readable through GET /v1/events for the rest of the event’s 90-day retention window. Both their own customer.* events and the buyer fields
denormalised onto events that merely reference them — invoices, checkout sessions — are rewritten.
The one exception is the customer.deleted event above, which keeps its pre-redaction snapshot on
purpose so the erasure is something your systems can act on.
What purge does not touch is the issued accounting documents. Each invoice, credit note and payment holds its own copy of the buyer’s name, email, VAT number and country, taken at the moment it was issued, so that a document never changes after the fact. Those copies survive, because an invoice you have issued is a record you are required to keep for 7–10 years depending on member state — and on a reverse-charged intra-EU B2B supply the buyer’s VAT number is a required element of the invoice, so removing it would make the document defective rather than private. That is the legal-obligation ground in GDPR Art. 17(3)(b). Plan your erasure process around it rather than around the assumption that one call removes every trace.
Purge refuses on the same subscription statuses as delete, and additionally with
409 operation_pending while a payment operation is in flight for the customer. Cancel first. A
paused subscription reads active, because the customer has paid for the period they are in, so it
is refused too.
Purge is the one call that still reaches a customer you have already deleted. Everything else
treats a soft-deleted customer as gone and answers 404, but delete-then-erase is the normal order
to do those two things in, so an erasure request weeks after the deletion still works. Purging
twice is a 404.
confirmed: true is required because DELETE and purge are one keystroke apart and only
one of them is reversible.
How the buyer’s country gets set
country_code is the tenant’s to set, and BillKit only ever fills a blank. There are three ways
it arrives, strongest first:
- You set it on
POST /v1/customers, or the customer sets it in the billing portal. This is an answer, and nothing later overwrites it. - The buyer answers it at checkout. When the customer carries no country, the embedded element shows a country select and will not let the payment through until it is answered. An unanswered country means no VAT is charged, and the tenant carries that.
- A suggestion prefills that select, resolved from the buyer’s IP address. It only fills the control in: the select stays visible, stays required, and what the buyer settles on is what is stored.
The third step never decides anything on its own, and that is deliberate. A stored country_code
outranks anything the buyer asserts afterwards, so a country inferred without their confirmation
would be one they could not correct at checkout.
The IP lookup runs against a local database inside BillKit, so the buyer’s address is never sent to a geolocation service. IP geolocation by DB-IP.
BillKit