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.
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.
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.
1. Declare the state set and legal transition table
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.
| 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 |
canceled → active 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.
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.
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.