BillKit/DocsConsole
Core concepts

Subscriptions

A subscription ties a customer to a price and renews on a schedule. It is created by a paid checkout, and it carries two independent state fields.

Updated Oct 14, 2026·OpenAPI v1.1.0

There is no create endpoint

POST /v1/subscriptions does not exist. A subscription appears when a checkout session’s payment settles: BillKit stores the mandate that payment produced, creates the subscription, and emits subscription.created. See the quickstart for the full call sequence.

The consequence to plan for is that creation is asynchronous. Your code does not receive a sub_... from any request it makes. It receives one over a webhook, typically within seconds of the customer finishing at Mollie, but not on the redirect back to your success_url.

Two state fields, not one

Most billing APIs collapse “is this customer entitled” and “will this renew” into one status. BillKit keeps them apart, because a cancelled subscription that is still inside its paid period is both cancelled and entitled, and one field cannot say that.

status answers: what is the billing state right now?

statusMeaning
incompleteImported from another processor and not yet paid through BillKit. A subscription created by checkout starts trialing or active and never shows this.
trialingInside the trial window. The mandate exists; the full price has not been charged yet.
activePaid and current. Also the status of a paused or cancelling subscription, see below.
past_dueA renewal failed. BillKit is retrying it, or waiting for the customer to pay it. See Recover failed payments.
canceledTerminal. No further charges.

There is no paused status. A paused subscription has been paid for up to current_period_end, so it stays active and only renewal_state changes. Asking for status=paused is a 400 naming the filter that does work.

renewal_state answers: what happens at current_period_end?

renewal_stateMeaning
auto_renewIt will renew. The normal state.
cancelingCancel was requested. Access runs to current_period_end, then stops.
pausedPause was requested. Same paid-through window, no renewal.
stoppedThe subscription is canceled. Nothing further will happen.

For entitlement, read serves_customer. It is a boolean on every subscription, and it answers the only question an application actually asks: should this customer have the product right now?

Gating on status alone is the mistake having two fields is meant to prevent. A paused or cancelling subscription keeps status: active right up to the end of its period, so a check that reads status alone keeps serving a customer who will never be charged again. Denying every pause is the opposite error: that customer has paid through the end of the current period and is entitled to it.

serves_customer combines both fields with the period boundary:

  • While renewal_state is auto_renew, entitlement follows status alone and current_period_end is ignored. Every healthy subscription spends a short while past its period end waiting for the renewal to settle, and denying service then would paywall your entire paying book once a cycle.
  • Under any other renewal state no further charge is coming, so entitlement lasts exactly as long as the period already paid for.
  • A subscription imported from Stripe that is still waiting for its customer to add a payment method is treated like a renewal that cannot be charged yet: while it renews, it entitles until 14 days after the period they paid Stripe for (or after the import, when that period had already ended, as for a paused Stripe subscription), and is canceled then if the customer has still not added one.

past_due entitles on purpose: the customer paid for the period they are in, and cutting them off the hour a card expires converts a payment problem into a cancellation. incomplete never entitles, because nothing has been paid yet.

The underlying fields stay on the object, so you can still compute this yourself if you need to. Prefer the boolean: it is computed server-side, and it moves when the rule does.

The object

FieldNotes
idstringsub_…
customer_id / price_idWho is billed, and for what. price_id changes only when a plan change settles.
status / renewal_state / serves_customerAbove.
current_period_start / current_period_endinteger | nullThe period paid for, in Unix seconds.
cancel_at_period_endbooleanSet to true by a cancel and cleared by pause, resume or reactivate. It stays true after a cancelled subscription reaches canceled.
canceled_at / cancellation_reasonSet when the subscription reaches canceled. The reason is a short code: period_end when a cancelled or paused subscription reached the end of its period (a trial counts as one), refund_issued, or payment_failed when a renewal period went unpaid: its retries ran out, or it passed the payment method’s unpaid limit. trial_expired_unpaid is no longer written: it appears only on subscriptions closed by an earlier release, which labelled a trial cancelled or paused during the trial that way. Such a trial now closes at trial_end with period_end. imported_from_stripe marks a subscription that had already ended at Stripe when you imported it; its canceled_at is when Stripe ended it. import_not_activated marks a subscription imported from Stripe whose customer never added a payment method: it is canceled two weeks after the period they paid Stripe for, and canceled_at is when that happened.
trial_start / trial_endinteger | nullThe trial window, when there is one.
last_payment_id / last_payment_at / payment_countThe most recent settled charge, and how many there have been.
next_charge_amount_centsinteger | nullWhat the next renewal will charge in minor units, with a recurring coupon and any exclusive VAT applied. Until the renewal is first attempted it follows the subscription as it is now (a coupon, a plan change, the customer’s VAT id or country, your tax rates). Once attempted, it is the amount fixed for that period, and every retry charges it. The same value on the list, on retrieve, on every mutation response and in every subscription.* webhook. Null when the subscription will not renew, and for a metered price.
discountobject | nullThe coupon bound to future charges: coupon_id, and ends_at, the Unix second from which a charge is billed at list price. ends_at is null for a forever coupon, and for a once or repeating coupon until its first discounted charge, because the window is measured from that charge. It is fixed from then on. The whole field is null when no coupon is bound, including after it expires.
payment_methodobject | nullWhat the next renewal charges. type is the mandate method: creditcard, directdebit (SEPA) or paypal. A customer who paid with iDEAL or EPS holds a SEPA mandate, so shows directdebit. brand, last4, exp_month and exp_year are set for a card and null otherwise. Null while an imported subscription is waiting for the customer to activate it.
dunning_attempts_notified / dunning_last_notified_at / next_payment_attemptFailed-renewal state. See Reading subscriptions.
livemode / created

expand=customer and expand=price inline those two objects, and expand=price also adds mrr_cents. expand=refund_eligibility says whether the last charge can be refunded now and for how much. See Expanding responses.

Cancelling

POST /v1/subscriptions/{id}/cancel takes no body and is always at period end. There is no immediate-cancel flag. It sets renewal_state to canceling and cancel_at_period_end to true, so BillKit will not charge the next renewal, emails the customer a confirmation if they have an address, and emits subscription.updated.

status stays active until the period is over. serves_customer turns false at current_period_end, so gate access on that. Shortly after current_period_end, once no renewal charge is in flight, a background job makes the subscription canceled, with cancellation_reason: "period_end" and canceled_at set to the period end, and subscription.canceled fires. Treat subscription.canceled as the signal to tidy up, not to cut access. A paused subscription whose period runs out is closed the same way.

Two cases around a renewal behave differently:

  • A renewal payment is in flight, such as a SEPA debit that takes days to settle or a retry being charged. The cancel is refused with 409 operation_pending and reason: "renewal_processing", and the message names the time to retry after. If the payment settles, the customer has paid for the new period, and a cancel made then takes effect at its end. If it fails, a retried cancel goes through.
  • The period has ended and its renewal was never sent, for example while a SEPA debit waits for its notice period or after the provider refused the charge. There is no period end left to wait for, so the cancel ends the subscription at once: status: canceled, cancellation_reason: "period_end", canceled_at set to the period end that has already passed, and subscription.canceled fires. The unpaid period is never charged and nothing is refunded. A metered subscription is the exception: past its period end it is waiting to bill that period’s usage, so the cancel is scheduled as usual.

Calling it twice is safe: an already-cancelled subscription is returned unchanged, and so is an already-cancelling one unless a plan change or payment-method update is in flight for the customer. While one is, every other cancel returns 409 operation_pending.

To reverse a pending cancel while the period is still running, use POST /v1/subscriptions/{id}/reactivate. It restores auto_renew, so the next renewal is charged as normal, and emits subscription.reactivated alongside subscription.updated. It refuses with 409 state_conflict once current_period_end has passed, or if the mandate is gone, because at that point there is nothing to restore and the customer has to go through checkout again. Reactivate only undoes a cancellation: on a paused subscription it is a 409 state_conflict that points you to resume.

Refunding on cancellation

By default a cancellation gives no money back, and a refund is a separate operation you issue yourself. If you want it to be automatic, set refund_on_cancel on the price:

refund_on_cancelWhat a cancellation refunds
noneNothing. This is the default.
fullWhat is left of the most recent charge after any earlier refunds.
proratedThe unused remainder of the period that charge paid for, by elapsed time.

Only the most recent charge is ever in scope, only while it is inside the price’s refund window, and only while the period it paid for is still running. A charge outside the window refunds nothing, exactly as a manual refund would be refused. A period that has already ended owes nothing under either setting, whatever the window: the customer had all of it. That covers a cancel after current_period_end, a past-due subscription, and one whose renewal was never sent. prorated refunds nothing if part of that charge has already been refunded or charged back. Cancelling during a trial refunds nothing under either setting, because the trial’s checkout charge paid for no period. To return it, refund it yourself, which ends the trial. The windows themselves, and the defaults a price inherits when you set none, are in Refunds.

refund_on_cancel and the two window fields are among the few things you can change on a price after it exists, so a policy change reaches the customers already on it:

shell
curl https://api.billkit.eu/v1/prices/price_7Hd3Wm9pQx2vRt5Kna \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"refund_on_cancel": "prorated", "refund_window_initial_days": 30}'

When a cancellation does issue a refund, under either setting, access ends immediately: the subscription moves to canceled with cancellation_reason: "refund_issued" and subscription.canceled fires. Refunding time the customer then keeps would be paying them to stay. When nothing is owed, the cancellation stays at period end as usual.

The refund is best effort. A cancellation is recorded whatever happens to the money, so if the refund cannot be created the cancellation still stands, BillKit retries the refund in the background while the charge’s refund window is open, and access ends when it goes through. You can also refund by hand. Watch refund.created and refund.succeeded rather than assuming.

If you would rather give money back yourself, that is a refund, a separate operation. Refunding the initial or renewal charge for the current period in full, counting anything already refunded, cancels the subscription immediately, because a full refund is the “undo this” gesture. A partial refund leaves it running, as does refunding an older charge for a period that is already over, and refunding a metered usage charge, a plan-change charge or a metered price’s checkout charge never cancels anything. The checkout charge that started a trial counts as the initial charge: refunding it in full ends the trial immediately.

Pausing

POST /v1/subscriptions/{id}/pause stops renewals but keeps the record and the paid-through window. It moves renewal_state to paused and leaves status at active, because the customer is still entitled to the period they already paid for. Pausing a cancelling subscription clears the pending cancellation. It emits subscription.paused alongside subscription.updated. A past_due subscription cannot be paused, because pausing would stop the retries of a period nobody has paid for: that is a 409 subscription_past_due. Cancelling it is allowed, except while a retry is being charged, which returns 409 operation_pending (reason: "renewal_processing"). The same holds just after the period ends while its renewal has not been charged yet (a SEPA debit waiting for its notice, a retry after a provider refusal): pause and plan changes return 409 operation_pending with the time to retry.

shell
curl -X POST https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/pause \
-H "Authorization: Bearer $BILLKIT_API_KEY"

POST /v1/subscriptions/{id}/resume puts it back to auto_renew and emits subscription.resumed. The customer is not charged on resume; billing picks up at current_period_end. Resume has the same two conditions as reactivate, both a 409 state_conflict when they fail: the period must not have expired, and the mandate must still exist. Once the period has run out, a paused customer has to check out again, so a long pause is in effect a cancellation. Resume only undoes a pause: on a subscription scheduled to cancel it is a 409 state_conflict that points you to reactivate, so subscription.resumed always means a pause ended and subscription.reactivated always means a cancellation was withdrawn.

shell
curl -X POST https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/resume \
-H "Authorization: Bearer $BILLKIT_API_KEY"

Pausing an already-paused subscription, or resuming one that is already renewing, returns it unchanged. Pausing a cancelled subscription is a 409 state_conflict. Pause is also refused on metered subscriptions, where it has no defined meaning: usage would keep accruing with nothing to bill it against.

Changing plan

Two calls. Preview first, then commit.

shell
curl https://api.billkit.eu/v1/subscriptions/sub_Pv9Kc3nX8mQ2rTyLbd/preview_update \
-H "Authorization: Bearer $BILLKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"target_price_id": "price_Nt8Ql3wZ7bR2vMxKye"}'
Response · 200 OK
{
"object": "plan_change_preview",
"target_price_id": "price_Nt8Ql3wZ7bR2vMxKye",
"target_price_cents": 9900,
"currency": "EUR",
"credit_cents": 1740,
"charge_cents": 9874,
"charge_net_cents": 8160,
"charge_tax_cents": 1714,
"refused": false,
"refusal_reason": null,
"proration_at": 1790000000,
"new_period_end": 1792592000
}

The preview writes nothing and charges nothing. The example moves to a €99.00 price with VAT added on top (tax_behavior: "exclusive"), for a customer taxed at 21% with €17.40 of credit. The arithmetic is done on net amounts, so both prices are first reduced to their VAT-exclusive value:

  • credit_cents is the unused remainder, by elapsed seconds, of what the customer actually paid for the current period, net of VAT and after any coupon. A period nobody paid for credits 0.
  • charge_net_cents is the target price, after a coupon that carries onto it, less that credit.
  • charge_tax_cents is VAT on that difference, at today’s rate for the customer’s current country, and charge_cents is the total the customer pays now.

The new period starts at proration_at and runs to new_period_end, one full interval of the target price. It does not continue the old period. On update that moment is the time of the update call, where the quote is taken, not the time the payment settles, so a SEPA charge that takes days to clear does not cost the customer those days.

While that payment is settling, BillKit does not start a renewal for the subscription, so the old price is never charged for time the new period already covers.

POST /v1/subscriptions/{id}/update with the same target_price_id commits it. The quote is recomputed server-side, so a stale preview cannot lock in an old number. The charge goes to the stored mandate and the plan actually switches when that payment settles, which means the subscription’s price_id does not change in the response to your update call. When the payment settles you get subscription.updated, subscription.plan_changed and payment.succeeded. If it fails you get payment.failed and the subscription stays on its old price.

If the provider refuses the charge on the spot, update answers 402 payment_refused and nothing changes: there is no payment and no payment.failed. If the provider does not answer, update answers 502 and the outcome is unknown. Retry with the same Idempotency-Key. Until that outcome is settled, other changes to the customer’s subscriptions return 409 operation_pending. BillKit settles it itself, by asking Mollie whether the proration charge exists and re-sending the request while that is safe, and gives up after about an hour. The block lasts that long at most; it is never aged out early, because an unknown charge may still be held at Mollie and a retry made then would take a second one.

A preview can come back refused. Check refusal_reason, because the two reasons need different advice. renewal_imminent means the net charge would be under 100 minor units: the renewal is close enough that switching now would bill twice for the same window. Nothing is scheduled: the renewal still charges the current plan, so wait for it to settle and make the change then. downgrade_not_supported means the credit is larger than the cheaper plan’s price. BillKit cannot bill a downgrade as an immediate change, so cancel at period end and start the cheaper plan with a new checkout when it lapses. update refuses the downgrade with 400 parameter_invalid and reason: “plan_change_not_supported”, and an imminent renewal with 409 state_conflict.

The other limits on plan changes, each a 400 unless noted:

  • The subscription must be active, and for update its renewal_state must be auto_renew and it must have a mandate. A trialing subscription, and on update a cancelling or paused one, is refused with 409 state_conflict.
  • A past_due subscription is a 409 subscription_past_due, on preview_update as well: the current period is unpaid, so there is no paid time to credit. Update the payment method first, which pays the period.
  • A payment method BillKit has stopped charging, after a chargeback said it cannot be charged again, is a 409 state_conflict with reason: "no_active_mandate" until it is replaced.
  • While a renewal payment is processing, both calls are a 409 operation_pending whose message names when to retry.
  • The target price must be active, and an archived or unknown one is a 404. Its product must be active too.
  • The currency must match, and the target must differ from the current price.
  • Neither side may be metered. Metered and licensed prices renew on completely different machinery, so switching between them means cancelling and starting a new checkout.

A plan change already in flight for the customer, or a payment-method update, makes a second update a 409 operation_pending.

Recovering a broken payment method

POST /v1/subscriptions/{id}/reauthorize_payment_method takes a return_url and returns a Mollie checkout_url, with verification_amount_cents and verification_currency for the payment the customer completes there.

  • On a past_due subscription to a licensed price that is still set to renew, that payment is the unpaid renewal, charged with the customer present, and the amount is the one every retry charges. When it is paid, the period moves on, the subscription returns to active and the dunning counters reset.
  • Otherwise it is a verification payment, and the amount can be zero: card-only accounts verify without charging anything. A past-due metered subscription stays past_due until its open usage invoice is charged again on the new mandate.

Either way, when it settles the new mandate replaces the old one and subscription.payment_method_updated fires with the new payment_method in data and the old one in previous_attributes. The customer can pick a different method there than the one they had, so a card subscription can come back as directdebit. The 200 on this call only means the page exists.

A non-zero verification charge is not refunded. Do not tell customers it will be.

Calling it again while the customer’s last attempt can still be completed returns that same checkout_url, so a customer who closed the tab is not charged twice. It is refused with 409 state_conflict on a canceled or incomplete subscription, and on an imported one still waiting for the customer to activate it.

The customer portal exposes the same flow, so most integrations link to the portal rather than building their own page.

A customer can also remove a saved payment method from the embedded checkout’s wallet. A subscription renewing on that method moves to another saved method of the same kind, the customer’s default when it qualifies, and subscription.payment_method_updated fires for it. If no method of that kind is left, the removal is refused with 409 payment_method_in_use, and while a charge on the method is still processing it is refused with 409 operation_pending.

Reading subscriptions

GET /v1/subscriptions returns subscriptions newest first and supports customer_id, status and renewal_state. The two state filters each take a comma-separated list, and an unknown value is a 400 naming the ones that work rather than a silently empty page. So is a query parameter the route does not have, such as customer= for customer_id=: it is a 400 parameter_unknown rather than ignored, because an ignored filter returns every subscription and looks like an answer.

shell
curl "https://api.billkit.eu/v1/subscriptions?status=active,past_due&limit=50" \
-H "Authorization: Bearer $BILLKIT_API_KEY"

Filter on renewal_state when the question is about the period boundary rather than about payments. renewal_state=paused is the paused cohort and renewal_state=canceling is the set that will lapse at the end of the period. Once one lapses it reads status=canceled and renewal_state=stopped. status=paused is rejected, because there is no such status.

The dunning fields on the object are dunning_attempts_notified (the notices BillKit sent), dunning_last_notified_at, and next_payment_attempt. That last one is the exact time BillKit charges the unpaid renewal again, and it is null when it will not.

There is no retry ceiling on the object. How many retries remain depends on the payment method and on why the charge was declined, and several declines stop retrying at once, so any fixed denominator would be wrong for a large share of real failures. Branch on next_payment_attempt being null instead. See the dunning guide.