Subscription Lifecycle
RESTHeartThis page follows a subscription from a brand-new signup through to cancellation, showing what each step does on the Stripe side and what changes in MongoDB.
flowchart TD
A[Entity created] --> B["No Stripe Customer\nplan = default-plan"]
B -- "POST /stripe/checkout" --> C[Customer created lazily]
C --> D[Stripe Checkout page]
D -- paid --> E[webhook: subscription.created]
E --> F["plan = gold\nstatus = trialing / active"]
F -- "POST /stripe/portal" --> G[Customer Portal]
G -- cancel --> H["cancel_at_period_end = true\naccess continues"]
H -- period ends --> I[webhook: subscription.deleted]
I --> J["plan = default-plan\nstatus = canceled"]
D -- abandoned --> B
1. Before any payment
A newly created entity has no Stripe Customer and no subscription.
GET /stripe/subscription still answers 200:
{
"plan": "free",
"active": false,
"licensed": false,
"cancel_at_period_end": false,
"seats": { "limit": 1, "licensed": 0, "available": 1, "over_limit": false }
}
Being unsubscribed is a legitimate state, not a missing resource — there is no 404 here.
status is absent because no Stripe subscription has ever existed.
No Stripe Customer is created up front
A Stripe Customer is created lazily, at the first checkout, and never when an entity is created.
This matters more than it looks: creating a Customer for every signup fills the Stripe account with records for entities that never pay — most of them, for most products — and every one of them is a row in the Dashboard, an object in exports, and a potential subject in a data-deletion request.
POST /stripe/portal therefore answers 402 Payment Required for an entity that has never checked out: the Portal would open on an empty page.
2. Checkout
POST /stripe/checkout
Authorization: Bearer <token>
Content-Type: application/json
{ "plan": "gold", "interval": "month" }
{ "url": "https://checkout.stripe.com/c/pay/cs_test_..." }
Redirect the browser to that URL. Stripe hosts the payment page — card details never reach your server.
| Code | Meaning |
|---|---|
|
Session created; |
|
Missing/invalid |
|
Not authenticated. |
|
No resolvable entity, or the caller may not manage its billing. |
|
The entity already has an active subscription — use the Portal to change plan. |
|
Stripe is unreachable. |
interval must be month or year, and the plan must declare the matching price id.
|
Note
|
The |
On success the entity’s Stripe Customer is created if it does not exist yet, and the id is stored atomically — two simultaneous first checkouts produce exactly one Customer.
3. Payment completes
Stripe redirects the browser to success-url and, separately, delivers webhook events.
|
Important
|
The redirect is not the confirmation. It only means the browser came back. The subscription becomes active when A frontend that unlocks features on the redirect alone will unlock them for a user whose payment later fails, and will look broken for a user who closes the tab before being redirected. |
The success-url page should call GET /stripe/subscription to read the real state.
Webhook delivery is typically immediate but not instantaneous; if the plan has not changed yet, retry after a moment rather than assuming failure.
After the event is applied:
{
"plan": "gold",
"status": "trialing",
"active": true,
"licensed": false,
"cancel_at_period_end": false,
"trial_end": "2026-03-01T00:00:00Z",
"current_period_end": "2026-03-01T00:00:00Z",
"seats": { "limit": 10, "licensed": 0, "available": 10, "over_limit": false }
}
Note licensed: false: paying does not license anyone, not even the buyer.
See [_4_assigning_seats].
Trials
If the plan declares trial-period-days (or default-trial-period-days is set), the subscription starts as trialing.
active is true during a trial, so a gate written as @subscription.active includes trial users — usually what you want.
To exclude them, test the status explicitly: equals(@subscription.status, "active").
Three days before the trial ends Stripe sends customer.subscription.trial_will_end, which can trigger the trial-will-end notification.
4. Assigning seats
Seats are consumed by explicit licences, not by team membership:
POST /stripe/licenses
Content-Type: application/json
{ "userId": "alice@example.com" }
The buyer usually licenses themselves first. See Seat Licensing for the full semantics.
5. Renewal
At each period boundary Stripe charges the payment method and sends invoice.payment_succeeded, which sets status to active and moves current_period_end forward.
Nothing is required from the application. A renewed subscription simply keeps working.
Failed payment
If the charge fails, invoice.payment_failed sets the status to past_due and can trigger the payment-failed notification.
|
Important
|
Whether that is the behaviour you want is a product decision. A card that expired is usually a temporary problem, and cutting access on the first failed charge can be harsher than intended — Stripe retries on its own schedule for days. To keep access during the retry window, gate on the plan rather than on
The plan id survives |
6. Changing plan
Plan changes go through the Customer Portal, not through a second checkout:
POST /stripe/portal
Authorization: Bearer <token>
{ "url": "https://billing.stripe.com/p/session/..." }
| Code | Meaning |
|---|---|
|
Session created. |
|
Not authenticated. |
|
The entity has no Stripe Customer — it has never checked out. |
|
No resolvable entity, or the caller may not manage its billing. |
|
Stripe is unreachable. |
The Portal handles proration, payment methods, invoices and cancellation. What it offers is configured in Stripe Dashboard → Settings → Billing → Customer portal, not in your configuration.
Changes made there arrive as customer.subscription.updated.
|
Note
|
A downgrade can leave the entity with more licences than the new plan allows. Nothing is revoked automatically — see Going over the limit. |
7. Cancellation
Cancelling in the Portal normally schedules the subscription to end at the period boundary:
{
"plan": "gold",
"status": "active",
"active": true,
"cancel_at_period_end": true,
"current_period_end": "2026-04-01T00:00:00Z"
}
The customer paid for the period, so access continues: active stays true until it actually ends.
cancel_at_period_end is what a UI should read to show "your plan ends on 1 April".
When the period ends, Stripe sends customer.subscription.deleted and the entity falls back to default-plan:
{
"plan": "free",
"status": "canceled",
"active": false,
"seats": { "limit": 1, "licensed": 3, "available": 0, "over_limit": true }
}
The subscription-canceled notification is sent at this point.
Note the entity is now over limit — three licences against a one-seat free plan. Nothing was revoked; the owner decides who keeps a seat, or resubscribes and restores everyone.
Reference: state transitions
| Event | plan |
status |
Notes |
|---|---|---|---|
(no subscription) |
|
absent |
|
|
the purchased plan |
|
|
|
the purchased plan |
|
|
|
unchanged |
|
Renewal |
|
unchanged |
|
|
|
unchanged |
unchanged |
|
|
|
|
May become over-limit |