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.
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_secretwhile the session isopen. 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 saysexpiredmeans the payment failed. That holds for a hosted session. An embedded session staysopenafter a failed or cancelled payment, so the customer can retry, and expires atexpires_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.updatedandinvoice.voidednow hold the fields that changed with their values before the change, as the subscription events already did. Before, they sentnulleven when something changed, which the contract defines as nothing changed.nullnow means that on every event. See Events. - Fix.
POST /v1/checkout/one_shotanswersamount_disputed_cents: 0, asGETand everyone_shot_payment.*event already did. The create response and its idempotent replay saidnull. - Documentation. The OpenAPI document declares the response headers.
Retry-Afteron429and503,X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reseton every authenticated response, andIdempotent-Replayedon the operations that take anIdempotency-Key. They were already sent. See Rate limits and Idempotency. - Behaviour change.
payment.failedis sent when a checkout’s first payment fails. It carries the payment withtype: "initial"and nosubscription_id. It is not sent when the buyer abandons the payment page or the attempt is cancelled: a hosted session then only moves toexpiredwithcheckout.session.expired. Do not assume everypayment.failedbelongs to a subscription. See Payments. - Behaviour change. A payment provider that does not answer gives
502, not500, on a write. Where no route answered for itself, the error now hasreason: "outcome_unknown": the provider may have acted. Treat every502on a write as an unknown outcome, with or without the reason, and retry with the sameIdempotency-Key. A read still answers500. See Errors. - Security fix.
expand=customergivesnullfor a deleted or purged customer, asGET /v1/customers/{customer_id}already gave404. Before, it embedded the deleted customer’s name and email. - Validation fix.
trueandfalseare refused wherever an integer is expected, with400 parameter_invalid: every amount (amount_cents,amount_off_cents,min_amount_centsand the rest), every timestamp (redeem_by,occurred_at, a test clock’sfrozen_time), and every count, limit and length (a usage record’squantity,max_redemptions,duration_in_months,trial_days, the refund windows, a tier’sup_to, a tax rate’srate_basis_points). Before, they were read as1and0, so{"amount_cents": true}created a one-cent price. - Behaviour change. A body that is not valid JSON gets
400with an emptyparamand the message “Request body is not valid JSON.” Before,paramwas 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
409with the new codewebhook_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"andsubscription.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 answers400and the customer is unchanged. - Additive. Erasing a customer also redacts the audit log.
POST /v1/customers/{customer_id}/purgeclears 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_dueperiod) and approve a billing-email change, but changing plan, cancelling, resuming, coupons and editing the customer’s details now answer403withreason: "portal_session_limited", and the portal hides those controls. Sessions you mint withPOST /v1/billing_portal/sessionsare 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 overGET /v1/events/streamand a retried webhook delivery of an older event can still carrydiscount_value,next_charge_amount,amount_dueandtax, and ISO 8601 timestamps ondunning.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_amountordiscount_valuerather than the new names. -
Published 0.x
billkitCLI binaries cannot trigger coupon events. Theirbillkit trigger coupon.createdandbillkit trigger coupon.updatedsenddiscount_value, which the API refuses with400 parameter_unknown. For the same reason theirlisten --events(which sendstypes) andcheckout one-shot --method(which sendsmethod) are refused. Upgrade the CLI to 1.0.0, where a one-off payment isone-shots create --payment-method. -
Breaking. Money fields on price tiers carry
_cents. In a tiered price’stiers,flat_amountbecomesflat_amount_centson create and on the returned price object.unit_amountbecomesunit_amount_centson create; it was only ever accepted on input, and the returned tier still states its unit price asunit_amount_decimal, which is unchanged. Every other money field in the API already ended in_cents; these were the exceptions. -
Breaking.
next_charge_amounton a subscription becomesnext_charge_amount_cents, for the same reason. -
Breaking. The
invoice.upcomingevent’s money fields carry_cents.amount_duebecomesamount_due_centsandtaxbecomestax_cents. Itsinvoice_idis now always present, andnullfor a renewal that has no open invoice. -
Breaking.
dunning.email_requiredtimestamps are Unix seconds.failed_atandnext_payment_attemptwere ISO 8601 strings, the only ones in the API; they are now integers like every other timestamp. -
Every object carries
object, and every list carriesobjectandhas_more. They were already sent; the OpenAPI document now marks them required. The plan change preview gainsobject: "plan_change_preview", andGET /v1/pinghas a documented response. -
previous_attributesis always present on events. It isnullwhen 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,typeandreason, and every other fixed set of values may gain values, as the compatibility contract says. No existing value changes, apart from the coupondiscount_typevaluefixed_cents, which becomesfixed_amount(below). -
The payment
typeenum documentssetup. It is the verification payment made when a customer adds a payment method from the embedded checkout’s wallet. Such payments were already returned byGET /v1/payments/{id}and sent onpayment.succeededandpayment.failed; the OpenAPI document now lists the value. -
A one-off payment no longer offers
directdebitas its method.POST /v1/checkout/one_shotalways 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 with400at validation, and the request schema in the OpenAPI document and the SDK request types no longer offer it. -
Idempotency-Keyis 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.purgeerases 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_offoramount_off_cents, notdiscount_value.discount_valuewas a percentage on one kind of coupon and minor units on the other. On the coupon object, thecoupon.*events andPOST /v1/coupons/validate, it is replaced bypercent_off(more than 0 and at most 100, to two decimal places, so12.5is 12.5% off) andamount_off_cents(minor units ofcurrency): both are always present, and exactly one is notnull.discount_typestays, and its fixed valuefixed_centsbecomesfixed_amount.POST /v1/couponstakes exactly one of the two fields, anddiscount_typebecomes optional there because the field you send implies it. The validation result also gainscurrency. -
Breaking. Validation failures are
400, never422. A request the schema refuses (a malformed body, a wrong type, an out-of-range value such aslimit=500) used to be a422, while the same kind of mistake caught by the route was a400. Every one is now a400, withcodeandparamas before. Code that branches on422should branch on400. -
Breaking. Unknown parameters are refused with
400 parameter_unknown. That includes a body field the route does not have, which was a422 parameter_invalid, and a query parameter the route does not have, which was silently ignored.paramnames it. A misspelt filter such ascustomer=forcustomer_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 were400 parameter_invalidand are now409withtype: "conflict_error"and noparam.reasonis 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_conflictandreason: "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 is404 route_not_foundand a method the path does not serve is405 method_not_allowed, with anAllowheader. Both used to beapi_errorwithcode: "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 get400 parameter_invalid. -
Breaking.
expandneeds the read scope of what it embeds.expand=customerneedscustomers:read, and so on (the table is on Expanding responses). A restricted key without it gets403 insufficient_scopewithparam: "expand". Full-access keys are unaffected. -
Breaking. API keys, audit log entries and webhook deliveries have prefixed ids:
ak_,al_andwhd_followed by 32 hex digits, like every other object. The bare UUID form answers404, and a bare UUID used as astarting_aftercursor is a400. Audit log entries made with an API key name it by itsak_id inactor_idandresource_id, soGET /v1/audit_logs?actor_id=ak_...finds what a key did. -
Breaking. The checkout session’s
objectischeckout.session, notcheckout_session, matching the event names. -
Breaking.
POST /v1/subscriptions/{id}/usage_recordsanswers200, not201. Every route now answers200on success. -
GET /v1/metrics/homeis 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_centsis filled everywhere a subscription appears: the list, retrieve, every mutation response and everysubscription.*webhook. It used to be filled on retrieve only andnullelsewhere. -
GET /v1/events?type=refuses an unknown type with400, as the event stream already did, rather than returning an empty page. -
Breaking. The event stream’s filter is
type, as onGET /v1/events.GET /v1/events/streamtooktypes; it now takestype, andtypesis refused with400 parameter_unknown. Both routes take one event type or a comma-separated list of them. -
The event stream resumes from any event id.
starting_afteronGET /v1/events/streamaccepts an id fromGET /v1/eventsor 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,openorpaid), sodraftis gone from the invoicestatusvocabulary, andGET /v1/invoices?status=draftis a400naming the valid values. -
An idempotent request body is compared as JSON. Key order and whitespace no longer matter; every value still does, so
1and1.0are 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 istype: "payment_error",code: "payment_refused". It wascode: "parameter_invalid"with noparam.reasonis stillpayment_refused, so code that branched onreasonkeeps working. -
Breaking. Every state refusal on
POST /v1/refundsis409 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, were400 parameter_invalid; they now answer like the same refusals on a subscription payment always did. An amount above what is left stays a400. -
Breaking. A required field left out is
400 parameter_missing, wherever it is caught. A field the schema requires wasparameter_invalidwhile the same omission caught by a handler wasparameter_missing. Both are nowparameter_missing, withparamnaming the field. A requirement that depends on the combination sent, such aspayment_methodon a hosted one-off payment, answers the same way. -
Breaking. Field renames. On checkout sessions and one-off payments, request and response,
methodbecomespayment_method, as on payments.reversedon a dispute becomesreversed_at.test_clockon a customer becomestest_clock_id, onPOST /v1/customersand on the customer object. -
Breaking. A customer purge returns a deletion marker.
POST /v1/customers/{customer_id}/purgereturned the redacted customer, which404s from then on. It now returns{"id", "object": "customer", "deleted": true, "purged": true}, likeDELETE. -
A one-off payment’s
metadatais always an object,{}when none was sent, as on every other resource. It wasnull. -
invoice.*events carry the invoice’slines. They were sent withlines: [], whileGET /v1/invoices/{id}returned them. -
The provider record of a purged customer’s payment is redacted.
GET /v1/payments/{id}/providergainsredacted. For a purged customer it istrueandpayloadkeeps only amounts, statuses, timestamps and identifiers; otherwise it isfalseandpayloadis 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
couponscapability defaults tofalse. Turn it on to offer a code field; a code then applies only on a plan whose product hasallow_promotion_codes: true, and attempts are rate-limited. See the portal guide. -
A billing portal session’s
subscription_idis 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:
statuson subscriptions, invoices, disputes and one-off payments,renewal_state, andtypeon both event routes. The comma-separated ones are arrays sent as?status=active,past_due. Every value the API accepted is still accepted.invoice_idandcustomer_idonGET /v1/credit_notes, andinvoice_idon 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/, answers404instead of redirecting to the path without it. - A blank or whitespace-only
Idempotency-Keyis refused with400 parameter_invalidandreason: "idempotency_key_blank". It used to be treated as no key. POST /v1/subscriptions/{id}/cancel, pause and plan changes are refused with409 operation_pendingandreason: "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/couponstakes it, and it is required withfixed_centsormin_amount_centswhen the account bills in more than one currency. Validating a coupon against a different currency returnsvalid: falsewithreason: "coupon_currency_mismatch". - The event stream,
GET /v1/events/stream, sends each event exactly as the webhook delivery carries it,previous_attributesincluded.
9 October 2026
POST /v1/checkout/one_shottakesui_mode. With"embedded",methodis omitted, no provider payment is created yet, and the response carries a short-livedclient_secretfor the payment element in@billkit-eu/js, where the payer chooses the method."hosted", the default, is unchanged and still requiresmethod.
29 September 2026
GET /v1/pingis 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
urlis on that domain. Do not check the URL against a fixed host.
26 September 2026
GET /v1/checkout/one_shotlists one-off payments, newest first, filtered bycustomer_idandstatus.GET /v1/payments/{id}?expand=refund_eligibilitysays whether a refund of the remaining balance would succeed now, and why not when it would not.- An explicit
nullclears these update fields: a product’sdescription, a customer’sname, a webhook endpoint’sdescription, a coupon’smax_redemptions,redeem_by,applies_to_price_idsandmin_amount_cents, a product’smarketing_features, a price’s refund window overrides, and a tax rate’sdisplay_name. Omitting a field still leaves it alone. - Every other update field refuses an explicit
nullwith400naming 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) andpayment_method(type, and for a card its brand, last four digits and expiry). - Products gain
default_price_id, set onPOST /v1/products/{id}and expandable with?expand=default_price. A product’s first price becomes its default. - Every subscription transition emits
subscription.updatedfirst and then the specific event, such assubscription.paused, both carryingprevious_attributes. An integration that handles onlysubscription.updatedsees every change of access. subscription.trial_endedfires 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
400that names the right route. - A retry with the same
Idempotency-Keyresumes its own unfinished operation instead of being refused by it. A different key still gets409 operation_pending. POST /v1/refundsrefuses a fully refunded charge with409 charge_already_refunded, a disputed charge with409 charge_disputed, and a refund the provider has already recorded with409 refund_duplicate. These are final answers, not retryable ones.GET /v1/subscriptions?expand=refund_eligibilityworks on the list as well as on retrieve.- Payment method verification charges emit
payment.succeededandpayment.failedlike every other charge. - A PDF that is not rendered yet answers
501withcode: "rendering_pending"in the standard error envelope. GET /v1/invoices?status=draftis 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
v1signatures, one per secret. - A subscription scheduled to cancel at period end reaches
status=canceledafter the period ends, and emitssubscription.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
epsandpaypalcan start a subscription, not only take a one-off payment. EPS renews over SEPA Direct Debit, PayPal over PayPal.banktransferis 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, ornullwhen 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}/voidvoids an invoice no money has moved on, emittinginvoice.voided. A paid invoice is refused withinvoice_not_voidable; issue a credit note instead. - One-off payments are invoiced when paid, and a refund of one issues a credit note.
BillKit