BillKit/DocsConsole
Policies

API changelog

Notable changes to the v1 API and webhooks since the public launch, newest first. SDK and CLI changes are in each package's own CHANGELOG.md.

Updated Oct 15, 2026·OpenAPI v1.0.0

This page lists changes an integration can observe: new and changed endpoints, fields, events and error codes, and behaviour changes that alter what a call does. Internal fixes that change nothing on the wire are left out. From 1.0, every entry is either additive under the compatibility contract or a deprecation under the deprecation policy, and says which.

15 October 2026

  • Fix. Every idempotent replay of an embedded checkout create returns a working client_secret while the session is open. Once the original secret had expired, the first replay minted a new one, but a later replay within that new secret’s 30 minutes returned the expired original again, and the element could not mount. See Embed Checkout.
  • Documentation. GET /v1/checkout/sessions/{checkout_id} no longer says expired means the payment failed. That holds for a hosted session. An embedded session stays open after a failed or cancelled payment, so the customer can retry, and expires at expires_at.

14 October 2026: after 1.0

  • Behaviour change. Every update event carries previous_attributes. customer.updated, product.updated, product.archived, price.updated, price.archived, coupon.updated, webhook_endpoint.updated and invoice.voided now hold the fields that changed with their values before the change, as the subscription events already did. Before, they sent null even when something changed, which the contract defines as nothing changed. null now means that on every event. See Events.
  • Fix. POST /v1/checkout/one_shot answers amount_disputed_cents: 0, as GET and every one_shot_payment.* event already did. The create response and its idempotent replay said null.
  • Documentation. The OpenAPI document declares the response headers. Retry-After on 429 and 503, X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset on every authenticated response, and Idempotent-Replayed on the operations that take an Idempotency-Key. They were already sent. See Rate limits and Idempotency.
  • Behaviour change. payment.failed is sent when a checkout’s first payment fails. It carries the payment with type: "initial" and no subscription_id. It is not sent when the buyer abandons the payment page or the attempt is cancelled: a hosted session then only moves to expired with checkout.session.expired. Do not assume every payment.failed belongs to a subscription. See Payments.
  • Behaviour change. A payment provider that does not answer gives 502, not 500, on a write. Where no route answered for itself, the error now has reason: "outcome_unknown": the provider may have acted. Treat every 502 on a write as an unknown outcome, with or without the reason, and retry with the same Idempotency-Key. A read still answers 500. See Errors.
  • Security fix. expand=customer gives null for a deleted or purged customer, as GET /v1/customers/{customer_id} already gave 404. Before, it embedded the deleted customer’s name and email.
  • Validation fix. true and false are refused wherever an integer is expected, with 400 parameter_invalid: every amount (amount_cents, amount_off_cents, min_amount_cents and the rest), every timestamp (redeem_by, occurred_at, a test clock’s frozen_time), and every count, limit and length (a usage record’s quantity, max_redemptions, duration_in_months, trial_days, the refund windows, a tier’s up_to, a tax rate’s rate_basis_points). Before, they were read as 1 and 0, so {"amount_cents": true} created a one-cent price.
  • Behaviour change. A body that is not valid JSON gets 400 with an empty param and the message “Request body is not valid JSON.” Before, param was a character offset such as "1".
  • Documentation. The usage-record list and the usage summary declare their 409, which they answer for a subscription that is not metered.
  • Behaviour change. A mode holds at most 16 webhook endpoints. Creating one more returns 409 with the new code webhook_endpoint_limit_reached. Disabled endpoints count; deleting one frees its place. See Webhooks.
  • Behaviour change. At most two webhook deliveries per endpoint, three per workspace in each mode, and four per workspace across both modes, are in flight at once. An endpoint whose receiver answers slowly now delays only its own deliveries, not the workspace’s other endpoints, and cannot take every place its workspace’s other mode needs. See Retries.
  • Behaviour change, made on release day before any live charge under 1.0. A full refund of a trial’s checkout charge ends the trial. The subscription is cancelled at once with cancellation_reason: "refund_issued" and subscription.canceled, as a full refund of any other first payment already did. Before, refunding that charge ended nothing and the trial ran on to its first charge. A partial refund still ends nothing, and so does a refund of a metered price’s checkout charge. See Refunds.
  • Additive. A VAT number that VIES rejects is recorded in the audit log as customer.vat_verification_failed. The call still answers 400 and the customer is unchanged.
  • Additive. Erasing a customer also redacts the audit log. POST /v1/customers/{customer_id}/purge clears the VAT number and registered name from that customer’s audit entries, and the IP address and user agent from entries the customer created through the billing portal or checkout.
  • Documentation. Every parameter and field in the API reference has a description, and the server SDKs’ generated types carry it.
  • Behaviour change. A billing portal session opened from one of BillKit’s emails can view and pay, and nothing else. The links in invoice, dunning, trial and cancellation emails stay valid for 90 days and can be forwarded, so their sessions can still show the subscription, documents and payments, replace the payment method (which pays a past_due period) and approve a billing-email change, but changing plan, cancelling, resuming, coupons and editing the customer’s details now answer 403 with reason: "portal_session_limited", and the portal hides those controls. Sessions you mint with POST /v1/billing_portal/sessions are unchanged. The trial-ending email no longer has a “Cancel trial” button, the cancellation email no longer has a “Reactivate” button, and the upcoming-payment email sends plan changes and cancelling to your own site. See Sessions from BillKit’s emails.

14 October 2026: 1.0

These are the last changes made outside the compatibility contract. They are server-side: they apply to every caller at once, whether it uses raw HTTP, a 0.x SDK, a 1.0.0 SDK or anything else. Upgrading an SDK does not decide when you get them. Code written against 0.x has to accept the new names, and keep accepting the old ones for as long as it reads the older records listed below.

Three things keep the old shapes around after 1.0:

  • Events recorded before 1.0 keep the shape they were recorded with. An event is stored once and never rewritten, so GET /v1/events, a replay over GET /v1/events/stream and a retried webhook delivery of an older event can still carry discount_value, next_charge_amount, amount_due and tax, and ISO 8601 timestamps on dunning.email_required. Handle both shapes for as long as you read events from before the release.

  • An idempotent replay can return a response in the old shape. A replay returns the response stored when the key was first used, and keys are kept for 24 hours. Repeating a write with a key first sent before the deploy, within 24 hours of it, returns the 0.x response, with next_charge_amount or discount_value rather than the new names.

  • Published 0.x billkit CLI binaries cannot trigger coupon events. Their billkit trigger coupon.created and billkit trigger coupon.updated send discount_value, which the API refuses with 400 parameter_unknown. For the same reason their listen --events (which sends types) and checkout one-shot --method (which sends method) are refused. Upgrade the CLI to 1.0.0, where a one-off payment is one-shots create --payment-method.

  • Breaking. Money fields on price tiers carry _cents. In a tiered price’s tiers, flat_amount becomes flat_amount_cents on create and on the returned price object. unit_amount becomes unit_amount_cents on create; it was only ever accepted on input, and the returned tier still states its unit price as unit_amount_decimal, which is unchanged. Every other money field in the API already ended in _cents; these were the exceptions.

  • Breaking. next_charge_amount on a subscription becomes next_charge_amount_cents, for the same reason.

  • Breaking. The invoice.upcoming event’s money fields carry _cents. amount_due becomes amount_due_cents and tax becomes tax_cents. Its invoice_id is now always present, and null for a renewal that has no open invoice.

  • Breaking. dunning.email_required timestamps are Unix seconds. failed_at and next_payment_attempt were ISO 8601 strings, the only ones in the API; they are now integers like every other timestamp.

  • Every object carries object, and every list carries object and has_more. They were already sent; the OpenAPI document now marks them required. The plan change preview gains object: "plan_change_preview", and GET /v1/ping has a documented response.

  • previous_attributes is always present on events. It is null when nothing changed, where it used to be absent. Code that tests for the key rather than the value needs to test the value.

  • Webhook deliveries are retried for about three days, over ten attempts, up from about four and a half hours. The schedule is on Webhooks.

  • Every enum is documented as open. Statuses, payment methods, error code, type and reason, and every other fixed set of values may gain values, as the compatibility contract says. No existing value changes, apart from the coupon discount_type value fixed_cents, which becomes fixed_amount (below).

  • The payment type enum documents setup. It is the verification payment made when a customer adds a payment method from the embedded checkout’s wallet. Such payments were already returned by GET /v1/payments/{id} and sent on payment.succeeded and payment.failed; the OpenAPI document now lists the value.

  • A one-off payment no longer offers directdebit as its method. POST /v1/checkout/one_shot always refused it: BillKit collects SEPA Direct Debit only for subscription renewals, over the mandate a first payment creates, and does not offer one-off direct debit. It is now rejected with 400 at validation, and the request schema in the OpenAPI document and the SDK request types no longer offer it.

  • Idempotency-Key is documented on every operation that accepts it, in the API reference and the OpenAPI document, so a generated client exposes it. The behaviour is unchanged; see Idempotency.

  • SDKs: purging a customer requires an explicit confirmation argument. customers.purge erases personal data irreversibly, so the 1.0.0 SDKs refuse to send it without one. The API call is unchanged.

  • Breaking. A coupon’s value is percent_off or amount_off_cents, not discount_value. discount_value was a percentage on one kind of coupon and minor units on the other. On the coupon object, the coupon.* events and POST /v1/coupons/validate, it is replaced by percent_off (more than 0 and at most 100, to two decimal places, so 12.5 is 12.5% off) and amount_off_cents (minor units of currency): both are always present, and exactly one is not null. discount_type stays, and its fixed value fixed_cents becomes fixed_amount. POST /v1/coupons takes exactly one of the two fields, and discount_type becomes optional there because the field you send implies it. The validation result also gains currency.

  • Breaking. Validation failures are 400, never 422. A request the schema refuses (a malformed body, a wrong type, an out-of-range value such as limit=500) used to be a 422, while the same kind of mistake caught by the route was a 400. Every one is now a 400, with code and param as before. Code that branches on 422 should branch on 400.

  • Breaking. Unknown parameters are refused with 400 parameter_unknown. That includes a body field the route does not have, which was a 422 parameter_invalid, and a query parameter the route does not have, which was silently ignored. param names it. A misspelt filter such as customer= for customer_id= used to return the whole list.

  • Breaking. A refusal because of an object’s state is 409 state_conflict. Pausing a canceled subscription, reactivating one whose period has run out, refunding an unpaid payment or one past its refund window, recording usage on a canceled subscription, deleting a customer with an active subscription: these were 400 parameter_invalid and are now 409 with type: "conflict_error" and no param. reason is unchanged where there was one.

  • Breaking. Deleting or purging a customer is refused while an invoice is still being collected from them, with 409 state_conflict and reason: "customer_has_open_invoice". The message names the invoice. Wait for it to be paid, or void it.

  • Breaking. A path or method with no route is invalid_request_error. A path that matches no route is 404 route_not_found and a method the path does not serve is 405 method_not_allowed, with an Allow header. Both used to be api_error with code: "unhandled", which read as a server fault and told SDKs to retry.

  • Breaking. A key of the wrong mode gets 403 mode_mismatch. A live key on test clocks, a test key writing portal branding or the billing profile, and any key naming the other mode’s Mollie credential used to get 400 parameter_invalid.

  • Breaking. expand needs the read scope of what it embeds. expand=customer needs customers:read, and so on (the table is on Expanding responses). A restricted key without it gets 403 insufficient_scope with param: "expand". Full-access keys are unaffected.

  • Breaking. API keys, audit log entries and webhook deliveries have prefixed ids: ak_, al_ and whd_ followed by 32 hex digits, like every other object. The bare UUID form answers 404, and a bare UUID used as a starting_after cursor is a 400. Audit log entries made with an API key name it by its ak_ id in actor_id and resource_id, so GET /v1/audit_logs?actor_id=ak_... finds what a key did.

  • Breaking. The checkout session’s object is checkout.session, not checkout_session, matching the event names.

  • Breaking. POST /v1/subscriptions/{id}/usage_records answers 200, not 201. Every route now answers 200 on success.

  • GET /v1/metrics/home is no longer part of the public API. It serves the dashboard’s home page, sums money across currencies and was never a contract. It is gone from the OpenAPI document and the reference, and the official SDKs do not expose it.

  • next_charge_amount_cents is filled everywhere a subscription appears: the list, retrieve, every mutation response and every subscription.* webhook. It used to be filled on retrieve only and null elsewhere.

  • GET /v1/events?type= refuses an unknown type with 400, as the event stream already did, rather than returning an empty page.

  • Breaking. The event stream’s filter is type, as on GET /v1/events. GET /v1/events/stream took types; it now takes type, and types is refused with 400 parameter_unknown. Both routes take one event type or a comma-separated list of them.

  • The event stream resumes from any event id. starting_after on GET /v1/events/stream accepts an id from GET /v1/events or a webhook as well as one the stream sent, without missing events. An event recorded at the same moment as the cursor can be sent again after a resume, so de-duplicate by event id. See Events.

  • Breaking. An invoice is never draft. No invoice was ever written in that status (each is issued numbered, open or paid), so draft is gone from the invoice status vocabulary, and GET /v1/invoices?status=draft is a 400 naming the valid values.

  • An idempotent request body is compared as JSON. Key order and whitespace no longer matter; every value still does, so 1 and 1.0 are different requests. See Idempotency.

  • A full refund ends the subscription only when it refunds a charge that pays for the current period: the one named by last_payment_id, or an earlier charge whose value a plan change credited or carried into the current period. Refunding a charge whose period is already over, in full, no longer cancels anything. Each charge is refunded under the window of the price it was made on. A plan-change charge that settles after the subscription has ended is refunded automatically. See Refunds.

  • Test mode only emails your own team. Customer-facing email in test mode goes only to an address that belongs to a member of your team; any other is skipped and the API call succeeds as usual. See Test mode.

  • Account-wide actions appear in both modes’ audit logs. Team changes, sign-in step-up, custom domains, branding and your billing details are filed as live and listed in test mode too.

  • Breaking. A refused charge or refund is 402 payment_refused. When Mollie refuses the payment a plan change, a past-due payment or a refund makes on the spot, the error is type: "payment_error", code: "payment_refused". It was code: "parameter_invalid" with no param. reason is still payment_refused, so code that branched on reason keeps working.

  • Breaking. Every state refusal on POST /v1/refunds is 409 state_conflict. Refunding a one-off payment that is unpaid, past its refund window or has refunds turned off, and refunding a subscription with no recorded payment, were 400 parameter_invalid; they now answer like the same refusals on a subscription payment always did. An amount above what is left stays a 400.

  • Breaking. A required field left out is 400 parameter_missing, wherever it is caught. A field the schema requires was parameter_invalid while the same omission caught by a handler was parameter_missing. Both are now parameter_missing, with param naming the field. A requirement that depends on the combination sent, such as payment_method on a hosted one-off payment, answers the same way.

  • Breaking. Field renames. On checkout sessions and one-off payments, request and response, method becomes payment_method, as on payments. reversed on a dispute becomes reversed_at. test_clock on a customer becomes test_clock_id, on POST /v1/customers and on the customer object.

  • Breaking. A customer purge returns a deletion marker. POST /v1/customers/{customer_id}/purge returned the redacted customer, which 404s from then on. It now returns {"id", "object": "customer", "deleted": true, "purged": true}, like DELETE.

  • A one-off payment’s metadata is always an object, {} when none was sent, as on every other resource. It was null.

  • invoice.* events carry the invoice’s lines. They were sent with lines: [], while GET /v1/invoices/{id} returned them.

  • The provider record of a purged customer’s payment is redacted. GET /v1/payments/{id}/provider gains redacted. For a purged customer it is true and payload keeps only amounts, statuses, timestamps and identifiers; otherwise it is false and payload is Mollie’s object verbatim. See Payments.

  • Deleting and purging a customer reach Mollie. A delete revokes the customer’s mandates at Mollie, and a purge also deletes the Mollie customer. Both run shortly after the call returns and are retried until Mollie confirms, so the response is unchanged. See Customers.

  • The billing portal’s coupons capability defaults to false. Turn it on to offer a code field; a code then applies only on a plan whose product has allow_promotion_codes: true, and attempts are rate-limited. See the portal guide.

  • A billing portal session’s subscription_id is documented as nullable. It is always set today; the type leaves room for a session that covers a whole customer.

  • List filters over a fixed vocabulary are documented as closed enums: status on subscriptions, invoices, disputes and one-off payments, renewal_state, and type on both event routes. The comma-separated ones are arrays sent as ?status=active,past_due. Every value the API accepted is still accepted. invoice_id and customer_id on GET /v1/credit_notes, and invoice_id on usage records, are capped at 64 characters like every other id filter.

  • Every operation documents its 500, with the same error envelope as any other status, so a generated client decodes it into its error type.

  • Fields added after 1.0 are optional in the OpenAPI document. New responses always carry them, but an older event or an idempotent replay may not, so a validating client must not require them. Every field that is in 1.0 stays required. See Versioning.

  • The compatibility contract takes effect, enforced by a breaking-change check on the OpenAPI document in CI.

13 October 2026

  • A request to a path with a trailing slash, such as /v1/customers/, answers 404 instead of redirecting to the path without it.
  • A blank or whitespace-only Idempotency-Key is refused with 400 parameter_invalid and reason: "idempotency_key_blank". It used to be treated as no key.
  • POST /v1/subscriptions/{id}/cancel, pause and plan changes are refused with 409 operation_pending and reason: "renewal_processing" while a renewal payment for that subscription is still settling. Retry once it settles.
  • Coupons with a fixed amount or a minimum amount carry a currency. POST /v1/coupons takes it, and it is required with fixed_cents or min_amount_cents when the account bills in more than one currency. Validating a coupon against a different currency returns valid: false with reason: "coupon_currency_mismatch".
  • The event stream, GET /v1/events/stream, sends each event exactly as the webhook delivery carries it, previous_attributes included.

9 October 2026

  • POST /v1/checkout/one_shot takes ui_mode. With "embedded", method is omitted, no provider payment is created yet, and the response carries a short-lived client_secret for the payment element in @billkit-eu/js, where the payer chooses the method. "hosted", the default, is unchanged and still requires method.

29 September 2026

  • GET /v1/ping is in the published OpenAPI document. It was always callable.
  • When an account has a verified custom domain for its billing portal, a portal session’s url is on that domain. Do not check the URL against a fixed host.

26 September 2026

  • GET /v1/checkout/one_shot lists one-off payments, newest first, filtered by customer_id and status.
  • GET /v1/payments/{id}?expand=refund_eligibility says whether a refund of the remaining balance would succeed now, and why not when it would not.
  • An explicit null clears these update fields: a product’s description, a customer’s name, a webhook endpoint’s description, a coupon’s max_redemptions, redeem_by, applies_to_price_ids and min_amount_cents, a product’s marketing_features, a price’s refund window overrides, and a tax rate’s display_name. Omitting a field still leaves it alone.
  • Every other update field refuses an explicit null with 400 naming the field. It used to be ignored.
  • Renewals are charged by BillKit against the stored mandate on its own schedule, for every subscription. No Mollie subscription is created any more.

25 September 2026

  • The subscription object gains discount (the bound coupon and when its discount ends) and payment_method (type, and for a card its brand, last four digits and expiry).
  • Products gain default_price_id, set on POST /v1/products/{id} and expandable with ?expand=default_price. A product’s first price becomes its default.
  • Every subscription transition emits subscription.updated first and then the specific event, such as subscription.paused, both carrying previous_attributes. An integration that handles only subscription.updated sees every change of access.
  • subscription.trial_ended fires exactly once per trial.
  • New event invoice.upcoming, sent before a renewal is charged with the amount it will charge.
  • Plan changes and pauses are refused while a subscription is past due, with subscription_past_due.
  • Resume undoes a pause and reactivate undoes a scheduled cancellation. Each refuses the other’s state with a 400 that names the right route.
  • A retry with the same Idempotency-Key resumes its own unfinished operation instead of being refused by it. A different key still gets 409 operation_pending.
  • POST /v1/refunds refuses a fully refunded charge with 409 charge_already_refunded, a disputed charge with 409 charge_disputed, and a refund the provider has already recorded with 409 refund_duplicate. These are final answers, not retryable ones.
  • GET /v1/subscriptions?expand=refund_eligibility works on the list as well as on retrieve.
  • Payment method verification charges emit payment.succeeded and payment.failed like every other charge.
  • A PDF that is not rendered yet answers 501 with code: "rendering_pending" in the standard error envelope.
  • GET /v1/invoices?status=draft is refused, because no invoice is ever a draft.

24 September 2026

  • For 24 hours after a webhook signing secret is rotated, each delivery carries two v1 signatures, one per secret.
  • A subscription scheduled to cancel at period end reaches status=canceled after the period ends, and emits subscription.canceled. Resume and reactivate refuse a canceled subscription.
  • The account’s own VAT id is set once. Changing or clearing it afterwards is refused with 400 vat_id_locked; contact support to change it.

23 September 2026

  • eps and paypal can start a subscription, not only take a one-off payment. EPS renews over SEPA Direct Debit, PayPal over PayPal.
  • banktransfer is accepted for one-off payments only. It settles when the payer’s transfer arrives, which can take days.
  • A request that arrives while the API is at capacity answers 503, which is retryable.

22 September 2026

  • Subscriptions publish next_payment_attempt, the time the next retry of a failed renewal is charged, or null when there will not be one. The retry ceiling fields that guessed at the provider’s retry policy were removed.
  • Events and other records are kept for fixed retention windows, listed on Events.
  • Purging a customer erases their personal data from the event log too, and works on a customer that was already deleted.

21 September 2026

  • Credit notes are available on the API, and POST /v1/invoices/{id}/void voids an invoice no money has moved on, emitting invoice.voided. A paid invoice is refused with invoice_not_voidable; issue a credit note instead.
  • One-off payments are invoiced when paid, and a refund of one issues a credit note.