BillKit/DocsConsole
Core concepts

Customers

A customer is the party you bill. The record is thin, because the payment credentials that matter live at Mollie, not here.

Updated Oct 14, 2026·OpenAPI v1.1.0

The object

FieldNotes
idstringcus_…
objectstringAlways customer.
emailstring | nullOptional. Where invoices, receipts, trial reminders and dunning notices go. A customer without one gets no email from BillKit.
namestring | nullUp to 255 characters. Shown on invoices and in the portal.
country_codestring | nullISO 3166-1 alpha-2, stored upper-case. This is what selects the VAT rate and filters the payment methods offered at checkout. When you leave it empty, BillKit fills it in from the buyer’s own answer at checkout or from what a settled payment shows, and never overwrites one you set. See Charge and record EU VAT.
vat_numberstring | nullSet through POST /v1/customers/{id}/vat_number, not on create or update. See VAT numbers.
vat_number_validatedbooleanTrue only when VIES confirmed the number.
metadataobjectString keys and values. Up to 50 keys, keys up to 40 characters, values up to 500. Echoed on the object and on its events.
test_clock_idstring | nullThe tc_… test clock this customer follows. Set at create time only. A live-mode key gets 400 parameter_invalid, and an unknown clock 404. See Test mode.
provisionalbooleanTrue while the customer exists only because a checkout captured their email and has not been paid. See Abandoned checkouts.
statsobjectOnly on GET /v1/customers?expand=stats: subscription_count, lifetime_cents (settled charges minus settled refunds) and currency. See Expanding responses.
livemodeboolean
createdintegerUnix seconds.

Every field is optional on create, including email. Request bodies reject unknown fields with 400 parameter_unknown, so a typo like ammount_cents fails loudly instead of being ignored.

shell
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. Send an Idempotency-Key so a retried create cannot make a second one.

Updating

POST /v1/customers/{id} takes email, name, metadata and country_code, and changes only the fields you send. "name": null removes the name. email and country_code cannot be cleared once set, only replaced. null on email, country_code or metadata is refused with a 400 parameter_invalid naming the field; omit a field to leave it unchanged. metadata replaces the whole object rather than merging into it: read it first if you only mean to add a key, and send {} to remove all keys. Every update emits customer.updated.

shell
curl https://api.billkit.eu/v1/customers/cus_Ja7Ye2vMq9xN4TdWpr \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Ada King", "metadata": {"workspace_id": "ws_4471", "plan_seat": "owner"}}'

A new country_code applies from the next charge. Nothing already invoiced is repriced.

If the customer has a VAT number, a new country_code re-checks it against VIES for the new country, because a registration is only valid for the country it was issued in. The update is never refused over it: when the pair does not validate, the country is still saved, vat_number_validated becomes false and VAT is charged until a valid pair is set.

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 a method is only ever added by a payment the customer makes themselves: the checkout that starts a subscription, a payment-method update, or adding a method in the Payment Method Element.

Customers who already have a mandate can replace the one a subscription renews on from the customer portal, and manage their saved methods in the Payment Method Element, which reads 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, because a manual override would be a one-call way to stop charging a customer VAT without a trace.

shell
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: a bare number fails that shape check. BillKit strips the prefix and sends the country to VIES as its own field.

Three outcomes after that, and every one of them is recorded in the audit log. An invalid number is recorded there and nowhere else: the customer is left as it was, and no event is sent.

OutcomeWhat happens
validVIES recognised the number. vat_number_validated becomes true, and a cross-border EU customer is reverse-charged from then on.
invalidThe call returns 400 parameter_invalid with reason: "vat_not_recognised". The attempt is recorded in the audit log as customer.vat_verification_failed, with the number and country that were sent, and nothing else changes: the customer keeps the registration and country they had before, and no customer.updated event is sent. Retrying with the same Idempotency-Key replays the 400 without a second check or a second audit entry.
unverifiableVIES was unreachable, the member state did not answer, the country is outside the register it covers (a UK or Swiss number, say), or the customer has no country_code to check against. 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 does not ask VIES again. 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. Every outcome that saves something emits customer.updated. The response is the customer.

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. That clearing is a customer.updated event, with provisional (and name, when the checkout supplied one the record lacked) in previous_attributes, so a mirror built from events sees the customer convert.

GET /v1/customers returns both kinds by default, newest first, and leaves out deleted and purged customers. There is no search by email or name, so keep your own mapping from your user to the BillKit customer id. Filter with provisional=false for people who have actually paid you:

shell
curl "https://api.billkit.eu/v1/customers?provisional=false" \
-H "Authorization: Bearer $BILLKIT_API_KEY"

And provisional=true for abandoned checkouts, the cart-recovery worklist:

shell
curl "https://api.billkit.eu/v1/customers?provisional=true" \
-H "Authorization: Bearer $BILLKIT_API_KEY"

The dashboard applies the same split: All leaves abandoned checkouts out, and Abandoned checkouts is its own tab.

Two things to know before you build on this. Provisional customers are deleted after a retention window of 30 days, along with their unpaid checkout sessions and failed payment attempts, so treat them as a recovery window rather than a durable record. The window counts from the customer’s last checkout or payment attempt, so starting a new checkout for one restarts it. 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. Their payments, invoices and refunds are untouched, and so is their personal data, so this is not an erasure. It cannot be undone, and deleting the same customer again is a 404.

shell
curl -X DELETE https://api.billkit.eu/v1/customers/cus_Ja7Ye2vMq9xN4TdWpr \
-H "Authorization: Bearer $BILLKIT_API_KEY"

The response is a deletion marker, not the customer:

Response · 200 OK
{
"id": "cus_Ja7Ye2vMq9xN4TdWpr",
"object": "customer",
"deleted": true
}

It is refused while the customer has a subscription that can still charge them, which is any subscription whose status is active, trialing, past_due or incomplete. A trial counts, because it turns into a real charge on its own. The refusal is a 409 state_conflict with reason: "customer_has_active_subscription". Nothing is cancelled for you, because a delete call should never end someone’s service on its own.

It is also refused while the customer still owes an open invoice. That happens after a metered subscription is canceled: its final invoice is issued open and BillKit keeps collecting it from the customer’s mandate, which a delete would revoke. The refusal is a 409 state_conflict with reason: "customer_has_open_invoice", and the message names the invoice. Wait for it to be paid, or void it if it should not be collected, then delete.

It is also refused with 409 operation_pending while a payment operation is in flight for the customer. A checkout still in progress has no subscription until its payment settles, so without this a delete in that window would leave a new subscription under a customer the API no longer shows.

Deleting also revokes every mandate the customer holds at Mollie, so nothing can charge a customer you can no longer see. This happens shortly after the call returns rather than inside it, so a Mollie outage never fails the delete; BillKit retries for several days until Mollie confirms. The Mollie customer record itself stays until you purge.

The subscription has to reach status: canceled first. A cancellation at period end gets there shortly after current_period_end, once no renewal charge is in flight, and so does a pause whose period has run out. A refund that ends the subscription, including a price’s refund_on_cancel, gets there at once.

POST /v1/customers/{id}/purge with {"confirmed": true} is the GDPR erasure. Email is overwritten with an unusable placeholder, name and metadata are cleared, and the VAT registration (vat_number and its validation state) is removed. country_code is kept, because it identifies nobody and explains the tax on the invoices that remain. The customer is also deleted, so it answers 404 from then on, while their invoices and payments still resolve.

shell
curl https://api.billkit.eu/v1/customers/cus_Ja7Ye2vMq9xN4TdWpr/purge \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"confirmed": true}'

The response is a deletion marker, {"id": "cus_…", "object": "customer", "deleted": true, "purged": true}, as for a delete: the customer cannot be read again. Without confirmed: true the call is a 400 parameter_invalid on confirmed and nothing changes. 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. Two sets of events are rewritten: their own customer.* events, and the buyer fields copied onto events that merely reference them, such as invoices and checkout sessions. On subscription events the card brand, last4, exp_month and exp_year under payment_method are nulled too; its type stays. Every event emitted about the customer after the purge is redacted the same way before it is stored or sent, so a later invoice.* event or a payment update for a past charge never delivers their data to your endpoint. Webhook deliveries still queued at the time of the purge send the redacted payload too, because a delivery reads the event when it is sent, not when it was queued. 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.

Your audit log is redacted too, and its entries stay so the history still reads correctly. Entries about the customer lose the VAT number and the trading name VIES returned for it, which for a sole trader is their own name; the country and the outcome of the check stay. Entries the customer made themselves, in the billing portal or an embedded checkout, lose the IP address and browser user agent they were made from. Entries made by your team or your API keys keep theirs, including entries about this customer, because that address is your staff’s, not the customer’s.

Purge also reaches Mollie. When checkout first charged the customer, BillKit created a Mollie customer carrying their name and email, and their mandates hold the account holder’s name and bank or card details. After the purge BillKit revokes every one of those mandates and deletes the Mollie customer. This runs shortly after the call returns rather than inside it, so a Mollie outage never fails or half-completes the purge: BillKit retries for several days until Mollie confirms.

Mollie does not delete the customer’s payments. A payment service provider is legally required to keep its payment records, so those stay with Mollie under its own retention obligations, and they still carry the payer’s details (account holder, IBAN or card). BillKit stops relaying them: for a purged customer’s payment, GET /v1/payments/{id}/provider returns Mollie’s record with every personal field removed and redacted: true. A request to erase what Mollie holds goes to Mollie, as the processor of those records.

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 to 10 years depending on member state. On a reverse-charged intra-EU B2B supply the buyer’s VAT number is a required element of the invoice too, 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, with the same error, and with the same 409 operation_pending while a payment operation is in flight for the customer. It also refuses while an invoice is still open, with the same customer_has_open_invoice refusal: settle the debt first, by waiting for the payment or by voiding the invoice to write the amount off, then purge. A paused or cancelling 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 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 so a purge is never sent by accident. Neither call can be undone, and a purge destroys the personal data BillKit and the Mollie customer record hold for good. The issued documents above, and the payment records Mollie is required to keep, are the exceptions.

How the buyer’s country gets set

country_code is the tenant’s to set, and BillKit only ever fills a blank. There are four ways it arrives, strongest first:

  1. 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.
  2. 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.
  3. 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.
  4. A settled payment, when the customer still has none: the country Mollie detected on its hosted page, else the country the card was issued in, else the country a single-country scheme such as iDEAL or Bancontact belongs to. This is how a hosted checkout learns a country you did not send. It changes what the next charge is taxed against, never the payment or invoice already issued.

You can stop customers changing their country, or their VAT number, from the billing portal. Set capabilities.customer_country_editable or capabilities.customer_vat_number_editable to false on POST /v1/tenant/portal_branding (or untick them in the console under Settings → Branding). The portal then shows the field read-only with a note to contact you, and refuses a change with 403 customer_field_locked; sending the value already on file is still accepted. Both default to true. Changes you make with your API key or in the console are never affected.

The IP suggestion never decides anything on its own. 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.