Skip to content
KrokoPay
Back to the blog

Product

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.

The five states

StatusMeaningTypical product behavior
trialingIn a trial before the first paid period.Full access, with a visible end date for the trial.
activeThe current period is paid and renews at current_period_end.Full access. Nothing to say unless they scheduled a cancellation.
past_dueThe latest renewal charge failed.A decision you make: grace with a clear warning, or restricted access.
pausedNo renewals are charged until it is resumed.Access suspended, data kept, an obvious way to resume.
canceledEnded. 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.

What triggers each transition

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).

FromToTriggerEvents
—activeThe first period is paid on a payment link, or the subscription is created through the API.subscription.created, charge.paid
activeactiveA renewal charge is paid and a new period starts.charge.paid, subscription.renewed
activepast_dueA renewal charge fails.charge.failed, subscription.past_due
trialing / active / past_duepausedYou pause it.subscription.paused
pausedactiveYou resume it. If the paid period already ended, a new one is charged right away.subscription.resumed, then subscription.renewed when charged
any except canceledcanceledImmediate cancellation, or the period ends with cancel_at_period_end.subscription.canceled

Information: Two rules still being defined

In the current API proposal, how a trial turns into a paid period and the retry policy for a failed renewal are to be defined. Do not hard-code assumptions about either. Design your handlers to react to the events you receive, whatever the timing turns out to be. Details in Subscriptions.

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.

What your product should do in each state

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.

Access

  • trialing and active: full access until current_period_end.
  • active with cancel_at_period_end: full access until the date in canceled_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.

Messaging

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.

Grace is a decision, not a number

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.

Mapping events to your database

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.

Mirror the subscription, keep the historyTypeScript
// 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 with GET /v1/subscriptions/{id} when in doubt.
  • Listen to charge.* as well. Each period is a charge, so charge.paid and charge.failed give you the receipts and failure reasons behind each transition.

More on delivery, signatures and ordering in Webhooks.

Mistakes to avoid

  1. Booleans like is_paying or is_active. They collapse five states into two and force every screen to guess. Store the status; derive access in one function.
  2. 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.
  3. Silent failures. A past_due subscription 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.
  4. Revoking access on cancellation requests. A scheduled cancellation is still a paid period. Wait for subscription.canceled.
  5. Treating paused as canceled. A paused customer intends to return. Keep their data and make resuming obvious.
  6. Reacting to the browser. The return from the checkout is not proof of payment. Grant access on events.

Try the states before you ship

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.

  • Engineering8 min read

    Webhooks without surprises

    Retries, duplicates and events that arrive out of order are not edge cases; they are what at-least-once delivery means. How to build a consumer that verifies, acknowledges fast, processes once and reconciles.

  • Product7 min read

    Anatomy of a checkout that converts

    Few fields, a total that never moves, one clear method choice, declines explained in plain words and a state for every outcome. A walk through what a payment page must get right, with our own checkout as the example.

Try it in the test environment

Create an account to get test keys, open the hosted checkout and send yourself a signed webhook. Nothing real is charged.

Commercial terms are to be announced. See what pricing covers so far