Subscription Lifecycle States

A subscription is not a row you mutate at will — it is a contract that walks a deterministic path, and the moment two code paths disagree on which state it is in, you leak revenue or bill a canceled customer. This page is part of Subscription Billing Architecture & Pricing Models and treats the lifecycle as an explicit finite state machine (FSM): a small set of canonical states, a fixed transition table, and guard clauses that make every illegal edge unrepresentable. Get the FSM right and downstream concerns — Proration Logic & Calculations, dunning, and ledger sync — become mechanical consequences of state changes rather than ad-hoc branches scattered through controllers.

Prerequisites

An FSM is only as trustworthy as the storage guarantees beneath it. Optimistic concurrency stops two workers from both winning a transition; the append-only audit table makes every state change replayable; the outbox keeps downstream systems consistent with the state you just committed. The stack below is the floor.

Lifecycle FSM prerequisites A versioned subscriptions row, an append-only event log, an outbox, a gateway-to-domain event map, and a UTC clock underpin the state machine. Guarded FSM transition Versioned row optimistic lock Event log append-only Outbox same txn Event map gateway→domain UTC clock no local time
Five storage guarantees underpin the state machine — weaken any one and illegal states become reachable.

Each item on that list is load-bearing, and it is worth spelling out exactly which bug appears when it is missing. Drop the version column and two request handlers that both read active, both decide to transition, and both write will produce a last-writer-wins overwrite: one of the two audit events is silently lost, and the row lands in a state that no single event chain can explain. Drop the append-only constraint on subscription_events and a well-meaning migration that “fixes” a bad row erases the only evidence of how the row got bad, which is precisely the evidence you need during a revenue dispute. Drop the outbox and you are left choosing between publishing to the webhook bus before the transaction commits — so a rollback leaves subscribers believing in a state that never existed — or publishing after commit, so a crash in the gap drops the notification entirely. The outbox exists to make that choice unnecessary: the intent to publish is committed atomically with the state, and a separate relay turns intent into delivery.

The clock deserves special emphasis because its failures are the most insidious. A subscription created at 2026-03-08T23:30:00 in America/New_York and renewed “monthly” will, if you store and compare local timestamps, drift by an hour across the March daylight-saving boundary and can bill a customer a day early or grant a day of free access. Store every trial_ends_at, current_period_start, and current_period_end as UTC with an explicit offset, do all arithmetic in UTC, and convert to the customer’s zone only at render time. The gateway event map is the last quiet prerequisite: providers name the same real-world fact differently across API versions, so pin the map in one module and treat invoice.payment_failed, charge.failed, and their historical aliases as synonyms for a single internal renewal_failed event rather than scattering provider strings through your transition logic.

Architecture & Data Flow

The FSM has five canonical states. Inputs are domain events (a checkout completes, a charge fails, a customer pauses). Processing is a guarded transition under a row lock. Outputs are an updated row, an audit event, and outbox rows that fan out to the ledger and webhook bus.

Subscription lifecycle state machine States trialing, active, past_due, paused, and canceled with the legal transitions between them. trialing active past_due paused canceled convert charge fails recovered pause resume cancel dunning exhausted
Every edge is a named event; any pair not drawn here is rejected by a guard before it touches the row.

The states carry precise meaning. trialing grants entitlements with no charge yet booked. active means the current period is paid and recognized. past_due means a renewal charge failed and dunning is running while access is usually retained. paused means entitlements are suspended by customer or operator action with no billing accruing. canceled is terminal — the contract is closed and no further automatic transition can revive it (reactivation creates a new subscription, never an edge back into the graph).

State is stored, not derived

A recurring temptation is to compute the current state on the fly from other columns — “if current_period_end < now() and no successful charge, it’s past_due”. Resist it. Derived state is not auditable, cannot be locked, and produces a different answer depending on when you ask, which means two services reading the same row at the same wall-clock second can disagree during the seconds around a boundary. Persist current_state as an explicit column that only the guarded transition writes, and treat every other column — trial_ends_at, current_period_end, paused_at — as an input the FSM reads, never as a source the state is inferred from. The one place derivation is legitimate is the reconciliation scan in step 4, and even there the scan does not decide the state; it merely nominates a candidate row and injects a named event that the same guarded transition must accept.

The domain event is the unit of change, not the API call

Controllers, admin actions, cron jobs, and gateway webhooks are all just producers of the same domain events. A support agent clicking “cancel” in an internal tool must funnel through the identical apply_event(subscription_id, "canceled", …) path a customer-initiated cancellation uses, because any second write path is a second place the transition table can be bypassed. When every mutation is expressed as (subscription_id, event, payload) you get one guard, one audit format, and one outbox contract, and the question “how did this subscription reach canceled?” always has an answer in a single table ordered by occurred_at.

Implementation Walkthrough

The build order matters. First encode the transition table as data so the rules live in one testable place. Then wrap every transition in a row lock and a single transaction so the state, the audit event, and the outbox row are atomic. Then make ingestion idempotent so redelivered webhooks are harmless. Finally, drive deadline transitions from a clock rather than trusting a webhook to arrive. Each step closes a class of bug the previous step left open.

FSM implementation sequence Declare the transition table, lock and transition atomically, make ingestion idempotent, then drive deadlines from a clock. 1 Table as data one place 2 Lock + txn atomic 3 Idempotent dedup ingress 4 Clock-driven deadlines
Each step closes a bug class the previous one left open — data-driven rules, atomicity, idempotency, then time.

Encode the FSM as data, not as nested if statements. A transition is legal only if the (from, event) pair maps to a target state.

from dataclasses import dataclass

# Canonical states
STATES = {"trialing", "active", "past_due", "paused", "canceled"}

# (from_state, event) -> to_state  — every edge that is NOT here is illegal
TRANSITIONS: dict[tuple[str, str], str] = {
    ("trialing", "trial_converted"):     "active",
    ("trialing", "trial_canceled"):      "canceled",
    ("active",   "renewal_failed"):      "past_due",
    ("active",   "paused_by_customer"):  "paused",
    ("active",   "canceled"):            "canceled",
    ("past_due", "payment_recovered"):   "active",
    ("past_due", "dunning_exhausted"):   "canceled",
    ("paused",   "resumed"):             "active",
}

def target_state(current: str, event: str) -> str:
    try:
        return TRANSITIONS[(current, event)]
    except KeyError:
        raise IllegalTransition(f"{current} --{event}--> (no legal edge)")  # ✗ reject

class IllegalTransition(Exception):
    pass

2. Lock the row, evaluate the guard, transition atomically

Read-modify-write on a subscription must be serialized. Take a row lock, compute the target, and write the new state, the audit event, and the outbox row in one transaction so they commit or roll back together.

from django.db import transaction
from django.utils import timezone

def apply_event(subscription_id: str, event: str, payload: dict) -> str:
    with transaction.atomic():
        sub = (
            Subscription.objects
            .select_for_update()              # serialize concurrent transitions
            .get(id=subscription_id)
        )

        to_state = target_state(sub.current_state, event)  # raises on illegal edge

        sub.current_state = to_state
        sub.version += 1
        sub.updated_at = timezone.now()
        sub.save(update_fields=["current_state", "version", "updated_at"])

        SubscriptionEvent.objects.create(           # ✅ append-only audit row
            subscription_id=subscription_id,
            from_state=sub.current_state,
            event=event,
            payload=payload,
            occurred_at=sub.updated_at,
        )
        Outbox.objects.create(                       # ✅ same-txn fan-out
            aggregate_id=subscription_id,
            event_type=f"subscription.{to_state}",
            payload=payload,
        )
        return to_state

3. Make event ingestion idempotent

Gateways redeliver webhooks. Derive an idempotency key from the provider’s event id and short-circuit before the FSM runs, so a redelivered renewal_failed never double-advances state. The deduplication mechanics live in Building Idempotent Webhook Handlers In Nodejs.

def ingest(provider_event_id: str, subscription_id: str, event: str, payload: dict):
    _, created = ProcessedEvent.objects.get_or_create(
        provider_event_id=provider_event_id,   # unique constraint
        defaults={"subscription_id": subscription_id},
    )
    if not created:
        return  # ⚠️ already applied — idempotent skip
    apply_event(subscription_id, event, payload)

4. Drive time-based transitions from a clock, not from webhooks

Trial expiry and pause windows are deadlines, not external events. A reconciliation job scans for subscriptions whose deadline has passed and injects the corresponding internal event, so a missing customer.subscription.trial_will_end webhook never strands a row.

-- Subscriptions whose trial deadline has passed but were never converted
SELECT id
FROM subscriptions
WHERE current_state = 'trialing'
  AND trial_ends_at <= now()
FOR UPDATE SKIP LOCKED
LIMIT 500;

Capture from_state before you mutate the row

One subtle ordering trap deserves a callout, because it corrupts the audit trail without ever raising an error. The transition writes the new state onto the in-memory object and only afterwards constructs the audit row; if the audit row reads from_state from that same object after the assignment, it records the destination twice and the “from” side of every event is lost. Snapshot the originating state into a local variable — from_state = sub.current_state — before the assignment sub.current_state = to_state, and pass the snapshot into the SubscriptionEvent. The audit table is worthless for reconstruction if half of every edge is missing, so this one line is not cosmetic; it is the difference between a replayable history and a log of destinations with no origins.

Make the reconciliation job re-entrant

The SKIP LOCKED scan in step 4 will be run by overlapping cron invocations the day it falls behind, so its per-row work must be idempotent in exactly the way webhook ingestion is. The safe shape is: the scan selects candidate subscription_ids under FOR UPDATE SKIP LOCKED, and for each candidate it calls the same guarded apply_event with a deterministic synthetic event id such as recon:trial_expiry:{subscription_id}:{trial_ends_at}. Because that id is stable for a given deadline, a second overlapping run that picks up a row the first run already transitioned short-circuits at the ProcessedEvent uniqueness check instead of double-firing. Never let the reconciliation job mutate the row directly; if it bypasses apply_event it also bypasses the guard, the audit append, and the outbox, and you have quietly built the second write path the previous section warned against.

Keep the outbox relay separate from the transition

The transition commits the outbox row; a distinct relay process publishes it. Keeping them separate matters because publishing is slow and failure-prone — a webhook endpoint times out, the ledger service is mid-deploy — and you never want that latency or those failures inside the transaction holding the subscription row lock. The relay polls unpublished outbox rows ordered by id, publishes each, and marks it delivered; a row that fails to publish is retried on the next poll with exponential backoff. Because consumers may see a message twice when a publish succeeds but the delivered-marking crashes, the internal event_type and aggregate_id give every consumer the key it needs to dedupe, which mirrors the idempotency guarantee on the ingress side.

Edge Cases & Failure Modes

Almost every lifecycle bug is a variation on one theme: an event arrives late, twice, or concurrently, and the naive handler applies it anyway. The map sorts the failures by that root cause so the mitigation is obvious — ordering needs a sequence, duplication needs idempotency, and concurrency needs a lock.

Lifecycle failure roots Out-of-order and late-recovery bugs need a sequence cursor; duplicate webhooks need idempotency; concurrent transitions need a row lock. Ordering late renewal_failed recovery after cancel → sequence cursor Duplication redelivered webhook double advance → idempotency key Concurrency pause + cancel race ambiguous final state → FOR UPDATE lock
Sort lifecycle bugs by root cause and the defense is immediate — sequence, idempotency, or lock.
Scenario Why it breaks Mitigation
Out-of-order webhooks (renewal_failed after payment_recovered) Late event re-enters past_due and triggers dunning on a healthy account Carry a monotonic event_sequence; reject events older than the applied sequence
canceledactive attempted on reactivation Revives a closed contract, corrupting revenue history No such edge exists; reactivation creates a new subscription row
Concurrent pause + cancel Two locks race; final state is ambiguous SELECT ... FOR UPDATE serializes; the second transition sees the new state and its guard rejects it
Trial-expiry webhook never arrives Row stuck in trialing, entitlements granted indefinitely Clock-driven reconciliation job injects trial_converted/trial_canceled
Dunning marks canceled while a late recovery succeeds Customer pays but loses access Make dunning_exhausted the only edge to canceled from past_due; recovery after cancel routes to a new subscription

The sequence cursor is per-subscription, not global

The out-of-order defense in the first row of that table needs one clarification that catches teams out: the monotonic event_sequence must be scoped to a single subscription_id, not to the whole stream. Gateways guarantee ordering per object, not across your entire account, so a global counter would reject a perfectly valid renewal_failed for subscription_id B merely because a later-numbered event for subscription_id A arrived first. Store the highest applied sequence on the subscription row and compare against it inside the same locked transaction that applies the transition. When an event arrives with a sequence at or below the stored high-water mark, drop it as a replay of already-applied history; when it arrives with a gap above the expected next value, you have genuinely missed an event and should hold it or trigger a targeted resync rather than applying it and papering over the hole.

Terminal states need a guard, not just an absent edge

It is tempting to rely on “there is no edge out of canceled, so nothing can happen to it.” That is true for the eight edges in the table, but ingestion still receives events for canceled subscriptions all the time — a final failed retry, a chargeback, a webhook for a refund. Each of those hits target_state("canceled", event), finds no entry, and raises IllegalTransition. The failure mode is not applying an illegal transition; it is treating that exception as a crash and retrying the webhook forever, or as a swallowed no-op that hides a real chargeback you needed to record. Handle IllegalTransition against a terminal state as an expected outcome: acknowledge the webhook so the gateway stops redelivering, and route genuinely money-moving events like chargebacks to the ledger and dispute workflow, which operate on the closed contract without reopening its state.

Clock skew between application and database

The reconciliation query compares trial_ends_at <= now(), and it matters whether now() is the database clock or the application server’s clock. Use the database’s now() inside the query so every worker shares one authority; if the application computes a cutoff timestamp in its own process and passes it in, a server whose clock has drifted forward by even a few minutes will expire trials early for every customer it happens to scan. Billing deadlines are exactly the place where a two-minute skew becomes a support ticket, so anchor time to a single source and never mix the two within one decision.

Performance & Scale

The FSM’s cost profile is unusual for a billing subsystem: it is dominated by lock contention on individual rows, not by table size, because each transition touches exactly one subscription. That means it scales horizontally almost for free as long as two things hold — the reconciliation scans are index-backed, and batch workers do not queue behind each other on the same rows. The diagram shows where the cost actually lives.

FSM scale profile Per-transition cost is one locked row plus three inserts; scale depends on partial indexes for reconciliation and SKIP LOCKED for parallel batch drains. 1 locked row + 3 inserts Partial indexes state + deadline SKIP LOCKED parallel drain Partition audit by month
Transition cost is bounded by lock contention, not row count — index the scans and drain batches with SKIP LOCKED.

The hot path is a single locked row read plus three inserts, so per-transition cost is bounded by lock contention, not row count. Index subscriptions(current_state, trial_ends_at) and (current_state, current_period_end) so reconciliation scans hit a partial index rather than the full table. Use FOR UPDATE SKIP LOCKED in batch jobs to let multiple workers drain deadlines in parallel without blocking each other. The subscription_events audit table grows unbounded — partition it by month and archive partitions older than your retention requirement to keep the index hot. Outbox rows should be deleted (or moved to a outbox_archive) by the relay after successful publish so the polling query stays cheap.

Lock contention concentrates on renewal batches

The horizontal-scaling story holds right up until the moment thousands of subscriptions share a billing anniversary. A cohort that all signed up on the first of the month renews on the first of the month, and the renewal worker will try to transition all of them within the same window. The rows do not contend with each other — each transition locks a distinct subscription_id — but the downstream gateway, the outbox relay, and any per-account rate limit absolutely do. Spread anniversary load by jittering renewal processing across a window rather than firing every first-of-month subscription at midnight UTC, and size the batch worker pool against the gateway’s rate limit rather than the database’s, because the gateway is the real bottleneck. A useful rule of thumb: if a single transition costs roughly one locked row read plus three inserts, a few milliseconds each, then a pool of a dozen workers clears tens of thousands of renewals per minute against the database alone, and the ceiling you actually hit is the payment processor’s requests-per-second cap.

Watch the outbox relay’s lag, not just its throughput

The metric that predicts incidents is not how many outbox rows the relay publishes per second but how old the oldest unpublished row is. Throughput can look healthy while a single poison row — malformed payload, a consumer rejecting one event_type — stalls an ordered relay behind it and the lag climbs steadily. Emit max(now() - outbox.created_at) over undelivered rows as a gauge and alert on it crossing a threshold measured in seconds, because that lag is the exact interval during which your ledger and webhook subscribers believe an out-of-date state. Pair it with a dead-letter path so one unpublishable row is quarantined instead of blocking the queue, and the relay keeps draining the rows behind it.

Testing Strategy

The FSM is one of the few billing components you can test to near-exhaustion, because its input space is the finite product of states and events. Enumerate it. The four test classes below — exhaustive edges, deterministic clock, idempotent replay, and a concurrency race — cover every way a transition can go wrong.

FSM test classes Exhaustive legal-and-illegal edges, mock-clock determinism, idempotent replay, and a pause-versus-cancel concurrency race. Exhaustive states × events legal + illegal Mock clock trial expiry deterministic Replay same event id advances once Concurrency pause vs cancel one commits
The FSM's finite input space makes near-exhaustive testing practical — enumerate the edges and race the locks.

Test the transition table exhaustively: assert every legal edge succeeds and assert that a representative sample of the (states × events) product that is not in the table raises IllegalTransition. Inject a mock clock so trial-expiry and pause-window tests are deterministic rather than wall-clock dependent. Replay the same provider_event_id twice and assert the state advances exactly once and only one audit row is written. Finally, run a concurrency test that fires pause and cancel against the same row from two threads and assert the final state is deterministic and only one transition committed.

Assert on the audit trail, not just the final state

A test that only inspects current_state after a sequence of events will pass even when the path taken was wrong — a row can arrive at active by a legal route or by a bug that skipped past_due entirely. Make the audit table a first-class assertion target: after driving a subscription through convert, fail, recover, and cancel, assert that subscription_events contains exactly those events in that order with the correct from_state/to_state on each edge. This catches the from_state capture bug described earlier, catches double-application that a state check would miss because the second application is a no-op transition to the same state, and documents the intended lifecycle as executable specification.

Property-based testing of reachability

Beyond enumerating individual edges, generate random sequences of events and assert an invariant that must hold no matter the path: a subscription that has ever reached canceled never appears in any later state, no subscription is ever both entitled and non-billing simultaneously, and the count of renewal_failed events without an intervening payment_recovered never exceeds your dunning retry limit. Property tests over random event streams surface ordering bugs that hand-written cases miss, because they explore interleavings a human would not think to write, and every failing sequence they find is a concrete, replayable reproduction you fold back into the exhaustive suite as a regression case.

Test against a real database, not an in-memory fake

The concurrency and locking behavior is the whole point of the design, and SELECT ... FOR UPDATE semantics simply do not exist in an in-memory test double. Run the concurrency race and the SKIP LOCKED reconciliation tests against the same database engine you deploy on, in a transaction isolation level that matches production, because a test that passes under SQLite’s coarse locking tells you nothing about how two Postgres workers actually interleave. The one-time cost of a containerized database in the test suite buys you confidence in exactly the code paths that lose revenue when they are wrong.

Frequently Asked Questions

Why model this as an explicit FSM instead of status flags? Status flags let any code path set any value, so illegal combinations (canceled and past_due) become representable and eventually occur. An explicit transition table makes the set of reachable states finite and auditable, and concentrates every business rule about ordering in one place you can unit-test.

Should dunning logic live inside the state machine? No. Dunning is a parallel workflow that observes past_due and emits payment_recovered or dunning_exhausted back into the FSM. Keeping it separate means the state machine stays a pure function of events and the retry schedule can change without touching transition guards.

How do I handle a gateway that sends paused I never requested? Map it through the same ingestion path and let the guard decide. If (active, paused_by_customer) is legal it applies; if the row is already canceled, the guard rejects it and you log a reconciliation discrepancy rather than silently overwriting a terminal state.

What about incomplete/incomplete_expired states from Stripe? Treat them as pre-active states that resolve into active or canceled. Model them explicitly if your checkout can leave a subscription provisional; otherwise collapse them into trialing/canceled at the ingestion boundary so your internal FSM stays small.

How should the audit trail relate to the ledger? The subscription_events log records why the state changed; the ledger records the money that moved as a result. They are separate concerns that must reconcile: every ledger posting should trace back to an audit event, and every state change that implies a charge or credit should produce a matching ledger entry. Reconciling the two nightly catches both dropped postings and phantom charges, which is why the outbox writes to both in the same transaction as the state change.

Do paused subscriptions still count toward MRR? No — a paused subscription accrues no billing, so it should drop out of active MRR while remaining recoverable. Model pause as a first-class state rather than a boolean flag on active, because the moment you overload active with a is_paused flag you reintroduce the illegal combinations the FSM exists to prevent, and every MRR query has to remember to exclude them.

Where should scheduled cancellations live — a state or a flag? A subscription that a customer has asked to cancel at period end is still active and still billing entitlements until that boundary, so it is not canceled yet. Model the intent as a cancel_at_period_end timestamp the FSM reads, not as a state, and let the clock-driven reconciliation job inject the canceled event when current_period_end passes. Making it a premature state change would strip entitlements early and refund-or-not becomes ambiguous; making it a real event at the boundary keeps the audit trail honest about when the contract actually closed.

How do plan upgrades and downgrades fit this FSM? They usually do not change lifecycle state at all — an active subscription that switches plans stays active, and the plan change is a separate event on the subscription that triggers proration rather than a lifecycle transition. Keep the two axes orthogonal: the lifecycle FSM answers “is this contract live, suspended, or closed?” while a plan-version history answers “what is the customer entitled to and at what price?”. Conflating them means every upgrade has to reason about dunning and every cancellation has to reason about proration, and the transition table explodes.

What isolation level does the row lock actually need? SELECT ... FOR UPDATE under Postgres READ COMMITTED is sufficient for the single-row transition, because the lock forces the second transaction to re-read the freshly committed current_state before its own guard runs, and the guard then rejects the now-illegal edge. You do not need SERIALIZABLE for the common case, which would only cost you serialization failures and retries. Reach for stronger isolation only when a transition’s guard reads rows other than the subscription itself — for example checking a related entitlement table — because then the row lock alone no longer covers everything the decision depends on.

Should canceled subscriptions ever be hard-deleted? No. The terminal canceled row and its full subscription_events history are financial records you will need for revenue reporting, tax, and dispute resolution long after the customer is gone. Deletion also breaks the invariant that reactivation creates a new subscription referencing the old one, which is how you preserve lifetime-value continuity. Anonymize personal data on the linked customer record to satisfy erasure requests, but keep the immutable state history and monetary events, ideally in partitions you can move to cold storage rather than drop.