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.
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 worth planning 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?
| status | Meaning |
|---|---|
| incomplete | Created but the first payment has not settled. Transient. |
| trialing | Inside the trial window. The mandate exists; the full price has not been charged yet. |
| active | Paid and current. |
| past_due | A renewal failed. Retries are running at Mollie. See Recover failed payments. |
| canceled | Terminal. No further charges. |
There is no paused status, and that is the point of having two fields. 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_state | Meaning |
|---|---|
| auto_renew | It will renew. The normal state. |
| canceling | Cancel was requested. Access runs to current_period_end, then stops. |
| paused | Pause was requested. Same paid-through window, no renewal. |
| stopped | 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
subscription keeps status: active indefinitely, because nothing ever closes a paused record out,
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_stateisauto_renew, entitlement followsstatusalone andcurrent_period_endis deliberately ignored. Every healthy subscription spends a short gap past its period end waiting for the renewal to settle, and denying service in that gap 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.
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 four 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.
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, tells Mollie to stop the recurring schedule, and emits subscription.updated. status
stays active until the period actually elapses, at which point the subscription becomes
canceled and subscription.canceled fires.
Calling it twice is safe: an already-cancelling or already-cancelled subscription is returned unchanged.
To reverse a pending cancel while the period is still running, use
POST /v1/subscriptions/{id}/reactivate. It restores auto_renew and re-provisions the schedule
at Mollie. It refuses 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.
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_cancel | What a cancellation refunds |
|---|---|
| none | Nothing. The default, and what every price created before this setting existed does. |
| full | The whole of the most recent charge. |
| prorated | The unused remainder of the period that charge paid for, by elapsed time. |
Only the most recent charge is ever in scope, and only while it is inside the price’s refund
window (refund_window_initial_days / refund_window_renewal_days). Those columns used to be a
permission, read when you asked for a refund; refund_on_cancel is what turns them into a
trigger. A charge outside the window refunds nothing, exactly as a manual refund would be refused.
Both settings end access immediately rather than at period end. Refunding time the customer then keeps would be paying them to stay.
The refund is best effort. A cancellation is recorded whatever happens to the money, so if the
refund cannot be created the subscription still cancels and you can 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 an initial or renewal charge 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, and refunding a metered usage charge never cancels anything.
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. POST /v1/subscriptions/{id}/resume puts
it back to auto_renew, subject to the same two conditions as reactivate: the period must not have
expired and the mandate must still exist. Pausing an already-paused subscription returns it
unchanged.
Pause and resume are refused on metered subscriptions, where they have no defined meaning: usage would keep accruing with nothing to bill it against.
Changing plan
Two calls. Preview first, then commit.
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"}' {
"target_price_id": "price_Nt8Ql3wZ7bR2vMxKye",
"target_price_cents": 9900,
"currency": "EUR",
"credit_cents": 1740,
"charge_cents": 9835,
"charge_net_cents": 8128,
"charge_tax_cents": 1707,
"refused": false,
"refusal_reason": null,
"proration_at": 1790000000,
"new_period_end": 1792592000
} The preview writes nothing. credit_cents is the unused remainder of the current period, by
elapsed seconds. charge_cents is what the customer pays now, including VAT, and the new period
starts immediately rather than continuing the old one.
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. Watch for
subscription.updated.
A preview can come back refused. When the credit leaves less than 100 cents to charge, the
renewal is close enough that switching now would bill twice for the same window. Wait for the renewal and change
plan after it.
Three hard limits on plan changes: the currency must match, the target must differ from the current price, and 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.
The subscription must also be in auto_renew. A cancelling or paused subscription is refused.
Recovering a broken payment method
POST /v1/subscriptions/{id}/reauthorize_payment_method with a return_url returns a Mollie
checkout URL for a small verification charge. When it settles, the new mandate replaces the old
one, a past_due subscription returns to active, the dunning counters reset, and
subscription.payment_method_updated fires.
This verification charge is not refunded. Do not tell customers it will be.
The customer portal exposes the same flow, so most integrations link to the portal rather than building their own page.
Reading subscriptions
GET /v1/subscriptions 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.
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. status=paused is rejected, because there is no such status.
The dunning fields on the object are dunning_attempts_notified (notices BillKit sent),
dunning_last_notified_at, and next_payment_attempt — when the provider is expected to try
again, or null when it will not, which is the same shape and meaning as Stripe’s
invoice.next_payment_attempt.
There is deliberately no retry ceiling. Mollie retries “up to 5 times, once a day, depending on
the failure reason”, and several reasons cancel the subscription on the first failure, 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.
BillKit