Subscription bugs are state bugs. A customer keeps access after canceling. A past-due account pays and stays locked out. A trial ends for someone without a card, and no rule says what happens next. Each of these is a transition nobody drew.
A state machine diagram, usually called a state diagram, lists every condition one object can be in and every event that moves it to another. Missing transitions show up in review instead of in support tickets. This post builds one for a SaaS subscription, using the statuses Stripe Billing documents so you can compare the model with a real system, and covers the notation as it comes up.
When to draw a state machine
Draw one when a single object has a lifecycle with rules: an order, a subscription, a payment, a support ticket, a deployment. The warning signs are familiar:
- A
statuscolumn with more than three values, and code that checks it in many places. - Boolean flags such as
is_canceledandis_pausedthat can contradict each other. - Arguments about whether something "can happen" from a given status.
A state diagram is the wrong picture for a conversation between several participants, where a sequence diagram shows the order of messages, and for a business process with many people and hand-offs, where a BPMN diagram fits better.
The notation
| Element | Notation | Meaning |
|---|---|---|
| State | Rounded rectangle | A condition the object stays in until an event arrives |
| Initial pseudostate | Small filled circle | Where the machine starts |
| Final state | Filled circle inside a ring | The machine has finished |
| Transition | Arrow labeled event [guard] / action |
Move when the event happens and the guard is true |
| Choice | Diamond | Guards on the outgoing arrows decide the target |
| Composite state | A state that contains states | Rules shared by everything inside |
| Internal behavior | entry /, exit /, do / |
Runs on entering, on leaving, or while in the state |
Every part of a transition label is optional. invoice paid is an event on its own, [setting: unpaid] is a guard on its own, and cancel / revoke access is an event with an action. The syntax and the symbols come from the UML 2.5.1 specification.
The subscription statuses
The example uses the statuses from Stripe's subscription overview, with automatic card payments:
| Status | What it means | Access |
|---|---|---|
incomplete |
The first payment is not done yet; the customer has 23 hours | No |
incomplete_expired |
The first payment never succeeded; nothing is billed | No |
trialing |
In a trial period | Yes |
active |
In good standing | Yes |
past_due |
Payment on the latest invoice failed or was not attempted | Your choice |
unpaid |
Retries are exhausted and your settings chose this outcome | No |
paused |
The trial ended without a payment method, and pause was chosen | No |
canceled |
Ended; Stripe describes it as a terminal state | No |
Many products keep access during past_due while payment retries run, and this example does too. Write that decision on the diagram; it is the one people forget.
Building the transitions
Take each state and ask which events can happen there.
- Start.
subscribe [trial]leads totrialing;subscribe [no trial]leads toincomplete. - First payment. From
incomplete,invoice paidleads toactive. If 23 hours pass without payment, the subscription moves toincomplete_expired, and Stripe voids the invoice. That state is final: to try again, the customer starts a new subscription. - Trial end.
trial endsgoes to a choice.[payment succeeds]leads toactive;[no payment method]leads topausedin this example. Stripe lets you choose what happens in that case: create an invoice (the default), cancel, or pause. Draw the setting you actually use, not all three. - Renewals. From
active,renewal payment failsleads topast_due, andinvoice paidbrings it back. - Retries exhausted. Another choice: depending on your settings, the subscription moves to
unpaidorcanceled, or stayspast_due. Fromunpaid, paying the invoice returns it toactive. - Exit.
cancelmovesactivetocanceled. Cancel at period end is not a separate state: the subscription staysactivewith a flag until the period ends, then moves tocanceled.
A composite state for access
Three states grant access here: trialing, active and past_due. Drawing them inside one composite state, Access granted, lets you write the rule once: entry / grant access and exit / revoke access. Any transition that leaves the composite revokes access, whichever state it starts from, so no path can forget it. Moving between states inside the composite, such as from trialing to active, does not leave it and does not run those actions.
Diagram contents
- Access granted
- trialing
- active
- past_due
- paused
- incomplete
- incomplete_expired
- unpaid
- canceled
- subscribe [trial]
- subscribe [no trial]
- invoice paid
- after 23 h / void invoice
- trial ends
- [payment succeeds]
- [no payment method]
- resume [payment succeeds]
- renewal payment fails
- retries exhausted
- [setting: unpaid]
- [setting: cancel]
- cancel
How to read it:
- Top. The start dot splits on the trial:
trialinginside the composite, orincompleteoutside it. Fromincomplete, payment leads toactive, and 23 hours without one lead toincomplete_expiredand the final state on the right. - Middle, Access granted.
trial endsreaches a choice:activewhen the payment succeeds,pausedon the left when there is no payment method. A failed renewal movesactivetopast_due, and payment moves it back. - Bottom. When retries are exhausted, a second choice sends the subscription to
unpaidorcanceled.unpaidcan still pay its way back toactive;canceledends in the final state. - Edges into the frame grant access, edges out of it revoke access.
From the diagram to code
The diagram maps to a transition table. Anything not in the table is rejected:
type Status = 'incomplete' | 'incomplete_expired' | 'trialing' | 'active'
| 'past_due' | 'unpaid' | 'paused' | 'canceled';
const transitions: Record<Status, Partial<Record<string, Status>>> = {
incomplete: { invoice_paid: 'active', expired: 'incomplete_expired' },
trialing: { trial_ended_paid: 'active', trial_ended_no_method: 'paused' },
active: { payment_failed: 'past_due', cancel: 'canceled' },
past_due: { invoice_paid: 'active', retries_exhausted: 'unpaid' },
unpaid: { invoice_paid: 'active' },
paused: { resumed: 'active' },
incomplete_expired: {},
canceled: {},
};
function next(status: Status, event: string): Status {
const target = transitions[status][event];
if (!target) throw new Error(`${event} is not allowed in ${status}`);
return target;
}
If the events come from a payment provider's webhooks, do not assume order. Stripe's webhook documentation says it does not guarantee that events arrive in the order they were generated. Treat each event as a reason to fetch the subscription's current status, then apply the transition to that.
Common mistakes
- Missing events. For every state, check every event. "What happens if the customer cancels while
past_due?" is a question the diagram should answer. - Flags drawn as states. Cancel at period end, a discount or a seat count are properties of a state, not states.
- Actions drawn as states. "Send dunning email" is an action on a transition or an
entrybehavior, not a place the subscription waits. - No final state. Show where the lifecycle ends, so nobody writes code that revives a canceled subscription.
- Guards that overlap or leave gaps. The guards leaving a choice must cover every case exactly once; add
[else]if needed. - Two lifecycles in one diagram. The subscription and its invoices have separate state machines. Link them with events instead of merging them.
Keep the diagram next to the code that implements it and review both in the same pull request, as described in architecture diagrams that don't rot.
Build it in Archboard
- Sign in with Google or GitHub and create a diagram.
- From the UML pack, add a State for each status.
- Add a Core Frame named Access granted around
trialing,activeandpast_due. - Add a small filled Core Shape set to an ellipse for the initial state, a Shape set to a diamond for each choice, and an Icon of a circle with a dot for each final state.
- Connect the states with edges labeled
event [guard] / action. - Add a UML Note with the entry and exit actions of the composite state.
The example uses 14 diagram nodes, within the Free plan. With an AI agent connected over MCP, you can paste your status enum and ask it to draft the states, then check every transition yourself.
FAQ
What is the difference between a state diagram and a flowchart? A flowchart shows the steps of a process in order. A state diagram shows the conditions one object can be in and which events move it between them, so it answers what is allowed rather than what happens next.
Should every value of my status column be a state? Every value that changes behavior should be. If two values behave the same in every case, merge them; if one value hides two behaviors, split it.
Where do webhooks fit in a state machine? They are events. Verify them, fetch the current status from the provider, and apply the transition, because events can arrive out of order or more than once.
Do I need a composite state? Use one when several states share a rule, such as access or billing. It turns a rule you would repeat on many arrows into one entry and one exit action.
How detailed should transition labels be? Name the event in domain terms and add a guard only where two arrows leave on the same event. Put the actions that matter for correctness, such as granting access, on the diagram.