Designing subscription billing states
Recurring billing is not a boolean. It is a small state machine with five states, a handful of events and a lot of product decisions. Here is how to model it, what to do in each state and the mistakes that cost the most.
Written by KrokoPay teamPublished 7 min read
Most subscription bugs start with one column: is_paying. It looks reasonable on day one, when every customer has either paid or not. Then a renewal fails at night, someone pauses for a month, someone cancels but still has two weeks left, and the boolean starts lying. Each of those situations needs different behavior in your product, and a single flag cannot express any of them.
A subscription is better understood as a state machine: a small set of states, explicit transitions between them, and an event for each transition. This article walks through the states a KrokoPay subscription can be in, what moves it from one to another, what your product should do in each, and how to mirror all of it in your own database.
| Status | Meaning | Typical product behavior |
|---|---|---|
trialing | In a trial before the first paid period. | Full access, with a visible end date for the trial. |
active | The current period is paid and renews at current_period_end. | Full access. Nothing to say unless they scheduled a cancellation. |
past_due | The latest renewal charge failed. | A decision you make: grace with a clear warning, or restricted access. |
paused | No renewals are charged until it is resumed. | Access suspended, data kept, an obvious way to resume. |
canceled | Ended. It cannot be resumed. | No paid access. A way to start a new subscription. |
Two fields complete the picture. current_period_end tells you until when the customer has paid. cancel_at_period_end tells you that an active subscription will not renew: the customer asked to leave but keeps what they already paid for, and canceled_at shows when it will end.
Every arrow in the machine has a cause and an event. Some causes are billing (a renewal is paid or fails), some are requests from you or your customer (pause, resume, cancel).
| From | To | Trigger | Events |
|---|---|---|---|
| — | active | The first period is paid on a payment link, or the subscription is created through the API. | subscription.created, charge.paid |
active | active | A renewal charge is paid and a new period starts. | charge.paid, subscription.renewed |
active | past_due | A renewal charge fails. | charge.failed, subscription.past_due |
trialing / active / past_due | paused | You pause it. | subscription.paused |
paused | active | You resume it. If the paid period already ended, a new one is charged right away. | subscription.resumed, then subscription.renewed when charged |
any except canceled | canceled | Immediate cancellation, or the period ends with cancel_at_period_end. | subscription.canceled |
Information: Two rules still being defined
Two transitions deserve a closer look. Scheduling a cancellation (cancel_at_period_end: true) does not emit an event at request time; subscription.canceled arrives when the period actually ends. And canceling immediately does not refund anything automatically: if your policy is to refund the unused period, refund the last charge yourself.
The billing system tells you what happened. Your product decides what it means for the customer. Write those decisions down per state, so support, engineering and design give the same answer.
trialingandactive: full access untilcurrent_period_end.activewithcancel_at_period_end: full access until the date incanceled_at. Removing access early punishes someone who paid.past_due: choose between a grace period with full access and a restricted mode (read-only, no new projects). The right answer depends on what you sell and what it costs you to serve a customer who may not pay.paused: suspend paid features but keep the customer's data, so resuming is instant.canceled: remove paid access. Keep data for as long as your own retention policy says.
Each state has one message the customer needs, and only one. In past_due, it is that the payment did not go through and how to fix it, with a direct path to pay. In paused, it is when they paused and how to resume. For a scheduled cancellation, it is the end date and a way to change their mind. Avoid alarming copy in active: a healthy subscription should be invisible.
How long a past_due customer keeps access is a product policy, not something billing should decide for you. Make it explicit: one constant in your code, one sentence in your help center, the same rule for everyone. And whatever the recovery path turns out to be, it reaches you as events: let the same handler that extends a normal period (subscription.renewed) restore access, instead of writing a special case for customers coming back from past_due.
Store the subscription's state as KrokoPay reports it, not a flag derived from it. Every subscription event carries the full subscription in data.object, so the simplest correct handler copies the fields you need and records the event.
// After verifying the signature and deduplicating on event.id.async function handleSubscriptionEvent(event: SubscriptionEvent) { const sub = event.data.object; // 1. Append to history first: never lose what happened. await db.subscriptionEvents.insert({ eventId: event.id, subscriptionId: sub.id, type: event.type, status: sub.status, occurredAt: event.created_at, }); // 2. Update the current state, ignoring events older than what we have. await db.subscriptions.upsertIfNewer(sub.id, event.created_at, { status: sub.status, currentPeriodEnd: sub.current_period_end, cancelAtPeriodEnd: sub.cancel_at_period_end, canceledAt: sub.canceled_at, });}// Access is computed from state, in one place.function hasAccess(s: StoredSubscription, now: Date, graceEndsAt: Date | null): boolean { if (s.status === "trialing" || s.status === "active") return true; if (s.status === "past_due") return graceEndsAt !== null && now < graceEndsAt; return false; // paused, canceled}- Deduplicate on the event id. Deliveries can repeat; the same
evt_…must not apply twice. - Discard stale events. Events can arrive out of order. Compare
created_at, or fetch the subscription withGET /v1/subscriptions/{id}when in doubt. - Listen to
charge.*as well. Each period is a charge, socharge.paidandcharge.failedgive you the receipts and failure reasons behind each transition.
More on delivery, signatures and ordering in Webhooks.
- Booleans like
is_payingoris_active. They collapse five states into two and force every screen to guess. Store the status; derive access in one function. - Overwriting history. If you only keep the latest status, you cannot answer "when did this customer go past due?" or "how many times have they paused?". An append-only event table costs little and answers both.
- Silent failures. A
past_duesubscription that nobody notices is lost revenue and a confused customer. Tell the customer, show it to your team and make the recovery path one click. - Revoking access on cancellation requests. A scheduled cancellation is still a paid period. Wait for
subscription.canceled. - Treating paused as canceled. A paused customer intends to return. Keep their data and make resuming obvious.
- Reacting to the browser. The return from the checkout is not proof of payment. Grant access on events.
In the test environment you can start a subscription from a payment link, pause it, resume it and cancel it with test keys, and watch the events your endpoint would receive. Walk through every row of the transition table once and check that your product shows the right thing each time. The Subscriptions page shows how it looks to your customers.
