APIs & developer platforms / Tutorial

Subscription Billing Is a State Machine: Trials, Proration, Dunning, and Webhooks

Model subscriptions as an explicit state machine driven by payment webhooks: states, transitions, proration rules, and a dunning sequence that recovers failed payments.

In this tutorial you will design a subscription system the way it actually behaves: as a state machine. You will define the states a subscription can occupy, the events that move it between them, and the rules for the three problems that break naive implementations — mid-cycle plan changes (proration), failed payments (dunning), and webhook events that arrive late, duplicated, or out of order. The code is TypeScript against a generic payment provider’s webhook shape; the design applies whether the provider is Stripe, Paddle, or a regional processor.

Why billing code rots without a state machine

Subscription logic usually starts as a boolean: isPaid. Then trials arrive, and someone adds trialEndsAt. Then a failed payment needs a grace period, so pastDueSince appears. Then pausing, then annual plans with a mid-cycle upgrade, then a customer who cancels but has paid through the end of the month. Each addition is a new field, and the real state becomes an implicit combination of all of them — isPaid && !pastDueSince && trialEndsAt < now — that no one can enumerate, test, or reason about.

The fix is to make the state a single explicit value and every change a named transition. Once you do, questions that were previously arguments become lookups: “can a customer in this state change plans?” is a row in a table, not a code review debate.

The states

Keep the state set small and aligned with what your product actually does differently in each state:

StateMeaningProduct access
incompleteCheckout started, first payment not yet confirmedNone (or a holding page)
trialingIn a free trial; no successful charge yetFull
activePaid and currentFull
past_dueA renewal charge failed; dunning in progressFull during grace, then limited
pausedDeliberately suspended by the customer or an adminNone until resumed
cancelledTerminal for this subscription; entitlement runs to period end if already paidUntil period end, then none
unpaidDunning exhausted without recoveryNone

Two distinctions matter. First, cancelled is not “access ends now”: a customer who cancels on the 10th after paying on the 1st keeps access until the period ends, so cancellation is a state with a scheduled entitlement end, not a deletion. Model cancelAtPeriodEnd as data on the subscription, and let the period-end event perform the final transition. Second, past_due and unpaid are different states because the product behaves differently: past_due is a recovery process with a deadline, unpaid is its failure outcome. Collapsing them is how grace periods quietly become infinite.

Events and transitions

The state changes only in response to events, and nearly all of them arrive as webhooks from the payment provider:

src/billing/events.ts
export type SubscriptionEvent =
  | { type: "checkout.completed"; subscriptionId: string }
  | { type: "invoice.paid"; subscriptionId: string; periodEnd: string }
  | { type: "invoice.payment_failed"; subscriptionId: string }
  | { type: "trial.ended"; subscriptionId: string }
  | { type: "subscription.pause_requested"; subscriptionId: string }
  | { type: "subscription.resume_requested"; subscriptionId: string }
  | { type: "subscription.cancel_requested"; subscriptionId: string }
  | { type: "period.ended"; subscriptionId: string }
  | { type: "dunning.exhausted"; subscriptionId: string };

The transition function is a reducer: given the current state and an event, it returns the next state plus the side effects to perform (update entitlement, send an email, schedule the next dunning step). Keeping side effects described rather than performed inside the reducer makes the whole machine unit-testable without a database or a mail server.

src/billing/machine.ts
import type { SubscriptionEvent } from "./events";
 
export type SubscriptionState =
  | "incomplete" | "trialing" | "active" | "past_due"
  | "paused" | "cancelled" | "unpaid";
 
export interface Transition {
  next: SubscriptionState;
  effects: string[]; // e.g. "grant-access", "start-dunning", "email-payment-failed"
}
 
const NO_OP = (state: SubscriptionState): Transition => ({ next: state, effects: [] });
 
export function transition(
  state: SubscriptionState,
  event: SubscriptionEvent,
): Transition {
  switch (`${state}:${event.type}`) {
    case "incomplete:checkout.completed":
      return { next: "active", effects: ["grant-access", "email-welcome"] };
    case "trialing:trial.ended":
      return { next: "incomplete", effects: ["await-first-payment"] };
    case "active:invoice.paid":
      return { next: "active", effects: ["extend-period"] };
    case "active:invoice.payment_failed":
      return { next: "past_due", effects: ["start-dunning", "email-payment-failed"] };
    case "past_due:invoice.paid":
      return { next: "active", effects: ["stop-dunning", "extend-period", "email-recovered"] };
    case "past_due:dunning.exhausted":
      return { next: "unpaid", effects: ["revoke-access", "email-final-notice"] };
    case "active:subscription.pause_requested":
      return { next: "paused", effects: ["revoke-access", "email-paused"] };
    case "paused:subscription.resume_requested":
      return { next: "active", effects: ["grant-access"] };
    case "active:subscription.cancel_requested":
    case "trialing:subscription.cancel_requested":
    case "past_due:subscription.cancel_requested":
      return { next: "cancelled", effects: ["email-cancelled"] };
    case "cancelled:period.ended":
      return { next: "cancelled", effects: ["revoke-access"] };
    default:
      // Unknown or inapplicable event for this state: record it, change nothing.
      return NO_OP(state);
  }
}

The default branch is a design decision, not laziness. Webhooks will deliver events that do not apply to the subscription’s current state — a late invoice.paid for a subscription that has since been cancelled, a duplicate of an event you already processed. A reducer that ignores inapplicable events is safe to feed anything; a reducer that throws on them turns normal provider behaviour into production incidents.

Webhooks: duplicates, ordering, and verification

Three properties of webhook delivery shape the handler:

They can be delivered more than once. Store every processed event ID in a table with a unique constraint, and check it before transitioning. If the ID is already recorded, acknowledge the delivery and stop. Idempotency is not an optimisation; without it, a retried invoice.paid extends the customer’s period twice.

They can arrive out of order. A payment_failed for an invoice the customer has already retried and paid can land after the invoice.paid. Defences: carry the provider’s event timestamp and ignore events older than the last event applied to that subscription, and design transitions so late events land in the default branch — past_due receiving a stale failure after recovery should be impossible if your states are consistent, because the subscription is no longer in a state where that failure applies. When an event references an invoice, check the invoice’s current status with the provider before acting on failures.

They must be verified. Every webhook endpoint must validate the provider’s signature over the raw request body before parsing it. An unverified billing webhook endpoint is an API that lets anyone grant themselves a subscription by POSTing JSON. Verify against the raw body, not a re-serialised parse — signature schemes sign bytes, and re-encoding changes bytes.

Your handler’s shape follows from all three:

src/billing/webhook-handler.ts
export async function handleWebhook(req: Request, db: Db, provider: Provider) {
  const rawBody = await req.text();
  const event = provider.verifyAndParse(rawBody, req.headers.get("signature"));
  if (!event) return new Response("invalid signature", { status: 400 });
 
  // Idempotency gate: one row per provider event ID.
  const inserted = await db.tryInsertProcessedEvent(event.id);
  if (!inserted) return new Response("already processed", { status: 200 });
 
  const sub = await db.getSubscription(event.subscriptionId);
  const { next, effects } = transition(sub.state, event);
 
  await db.updateSubscriptionState(sub.id, next);
  await db.enqueueEffects(sub.id, effects);
  return new Response("ok", { status: 200 });
}

Return 200 only after the event is durably recorded. If processing fails midway, a non-200 response makes the provider retry — which is what you want, because your idempotency gate makes the retry harmless.

Proration: changing plans mid-cycle

When a customer upgrades on day 10 of a 30-day cycle, they should be charged for the difference for the remaining 20 days, not a full new month and not nothing until renewal. That adjustment is proration. Two policies exist, and choosing between them is a business decision the state machine must encode consistently:

  • Prorate and charge immediately. The upgrade takes effect now; an invoice is created for the unused portion of the old plan credited against the remaining portion of the new plan. Best for upgrades, where the customer expects the new limits now.
  • Prorate at renewal. The change is scheduled; the current cycle finishes untouched and the new plan starts at the next period. Best for downgrades, where immediate effect would mean clawing back capacity the customer has already used.

Whichever you choose, two implementation rules hold. First, let the payment provider compute the proration amounts — every provider implements the day-count arithmetic, credit balances, and tax interaction, and reimplementing it locally guarantees a slow drift between what your UI quotes and what the invoice says. Fetch a preview invoice from the provider and show the customer those numbers before confirming. Second, treat the plan change as an event into the same machine: a failed proration invoice is a payment failure and must flow into dunning, not into a special-case code path that forgets to.

Dunning: the recovery sequence

A failed renewal is usually not a lost customer; it is an expired card, a temporary limit, or a bank hiccup. Dunning is the structured attempt to recover before giving up, and it belongs in the state machine as the behaviour of the past_due state:

  1. Retry on a schedule. The provider retries the charge over the grace window (smart retry logic times attempts; if your provider lacks it, space a small number of retries across the window rather than hammering daily, which can trip issuer fraud controls).
  2. Email at each meaningful step. First failure: “your payment failed, here’s how to update your card” — with a direct link to the billing page, no login wall beyond what’s necessary. Mid-window: a reminder. Final notice: the exact date access ends. Every email’s job is to get the card updated; once it is, the provider’s retry (or an immediate manual retry you trigger) produces invoice.paid and the machine returns to active.
  3. Keep access during grace, end it deterministically. Access continues while past_due; when the window closes, your own scheduler emits dunning.exhausted, the machine moves to unpaid, and access is revoked by the effect — not by a date comparison sprinkled through your authorisation checks. Entitlement checks read the state; they never compute it.

Testing the machine

The payoff of the reducer design is that the entire billing behaviour is testable as a table: given this state and this event sequence, expect this state and these effects. Write those tests for the sequences that lose money when wrong — trial ending without a payment method, failure then recovery inside grace, cancellation during past_due, a duplicated invoice.paid, an out-of-order failure arriving after recovery. If a sequence is not in the table, its behaviour is currently “whatever the code happens to do,” which in billing means it is a bug you have not met yet.

Verdict

Model the subscription as data — one state field — and the billing behaviour as a transition table driven by verified, idempotently processed webhooks. Let the provider own money arithmetic, including proration; let your machine own entitlement and communication. The implementations that survive contact with real customers are not the ones with the cleverest code, but the ones where every weird sequence — late events, duplicates, mid-cycle changes, recovered cards — has an explicit, tested answer.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading