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 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?
| status | Meaning |
|---|---|
| incomplete | Imported from another processor and not yet paid through BillKit. A subscription created by checkout starts trialing or active and never shows this. |
| trialing | Inside the trial window. The mandate exists; the full price has not been charged yet. |
| active | Paid and current. Also the status of a paused or cancelling subscription, see below. |
| past_due | A renewal failed. BillKit is retrying it, or waiting for the customer to pay it. See Recover failed payments. |
| canceled | Terminal. 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_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 | The 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_stateisauto_renew, entitlement followsstatusalone andcurrent_period_endis 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
| Field | Notes |
|---|---|
| idstring | sub_… |
| customer_id / price_id | Who is billed, and for what. price_id changes only when a plan change settles. |
| status / renewal_state / serves_customer | Above. |
| current_period_start / current_period_endinteger | null | The period paid for, in Unix seconds. |
| cancel_at_period_endboolean | Set to true by a cancel and cleared by pause, resume or reactivate. It stays true after a cancelled subscription reaches canceled. |
| canceled_at / cancellation_reason | Set 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 | null | The trial window, when there is one. |
| last_payment_id / last_payment_at / payment_count | The most recent settled charge, and how many there have been. |
| next_charge_amount_centsinteger | null | What 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 | null | The 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 | null | What 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_attempt | Failed-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_pendingandreason: "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_atset to the period end that has already passed, andsubscription.canceledfires. 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_cancel | What a cancellation refunds |
|---|---|
| none | Nothing. This is the default. |
| full | What is left of the most recent charge after any earlier refunds. |
| prorated | The 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:
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.
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.
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.
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"}'{
"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_centsis 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 credits0.charge_net_centsis the target price, after a coupon that carries onto it, less that credit.charge_tax_centsis VAT on that difference, at today’s rate for the customer’s current country, andcharge_centsis 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 forupdateitsrenewal_statemust beauto_renewand it must have a mandate. A trialing subscription, and onupdatea cancelling or paused one, is refused with409 state_conflict. - A
past_duesubscription is a409 subscription_past_due, onpreview_updateas 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_conflictwithreason: "no_active_mandate"until it is replaced. - While a renewal payment is processing, both calls are a
409 operation_pendingwhose 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_duesubscription 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 toactiveand 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_dueuntil 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.
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.
BillKit