Billing lifecycle
What cancellation, renewal and payment failure do to a tenant: subscription statuses and the 402 gate, the grace, suspend, destroy timeline, and the billing events your app reacts to.
The lifecycle behind the billing calls
Sell & manage subscriptions shows the client calls — checkout, change, cancel. This page is the server-side story those calls set in motion: what a subscription's status means for your app, what happens on cancellation, renewal, and payment failure, and the signals your app can react to at each step. You need it because your app's requests are gated on the live subscription (gate 3 of the five gates), and because tenant suspension and teardown are consequences of billing state.
Subscription statuses, and which ones your requests pass
A subscription is always in one of these states:
| Status | Meaning | Gate 3 (require_active_subscription) |
|---|---|---|
trialing | In a free trial started without a card | Passes |
active | Paid and current | Passes |
past_due | A renewal charge failed; the provider is retrying | Passes during the payment grace window (reason payment_grace, with an update_payment banner); after it, 402 — payment_past_due |
cancelling | Cancellation requested, but the period already paid for has not ended yet | Passes |
cancelled | Cancelled, and the paid period has ended | 402 — subscription_cancelled |
expired | Reached its end without renewal | 402 — subscription_expired |
When the gate blocks, the 402 body is actionable — it tells the customer what to do next, so render it rather than swallowing it:
{
"success": false, "status": "error", "error_code": 402,
"reason": "payment_past_due",
"message": "Your payment is past due.",
"action": { "kind": "update_payment", "label": "Update payment method", "url": "/billing/payment-method" }
}A past_due subscription is not blocked at once. Where the sign-up journey is on, the gate reads the billing standing: while it is in grace (the billing.lifecycle.enforcement.payment_grace_days setting, default 3 days, measured from when the subscription became past_due) the request passes with reason: payment_grace, a message carrying the grace end date, and an update_payment action, so your UI can show a banner. Once the grace ends, or where the journey is off, past_due blocks with payment_past_due.
reason → action pairs: payment_grace → update_payment (not a block), payment_past_due → update_payment, subscription_expired → renew, subscription_cancelled → resubscribe, and no subscription at all → choose_plan. One nuance worth knowing: after the grace, a past_due org's features still resolve — plan lookups and entitlement data keep working so your UI can render — but the request gate blocks. Fail states are enforced per request, so a status change takes effect on the customer's next call; nothing needs redeploying.
Cancellation
When the customer cancels — through your UI via cancelSubscription(), or in the payment provider's hosted portal — the Control Plane:
- Cancels with the payment provider first. If the provider call fails, nothing changes locally — the subscription stays as it was and the API returns an error. (Cancelling is owner-only and permission-gated in the portal tier.)
- Marks the subscription
cancellingand stampscancelled_atwith the moment of the request.expires_atholds the end of the period the customer has already paid for. Gate 3 keeps passing — the customer paid through the period and keeps their access until it ends. - Emits
cp.subscription.cancelled— your app's signal to react. Readeffective_at: it is the moment access actually ends, which for a mid-period cancellation is in the future. Schedule against it rather than revoking on receipt. - Closes out to
cancelledwhen the period ends — whichever comes first, the provider's deletion webhook or the daily lifecycle sweep. From that moment gate 3 returns 402subscription_cancelled. Only onecp.subscription.cancelledis emitted per cancellation; the close-out is not a second event.
To cancel immediately instead — for fraud or a compliance obligation — an operator can cancel with at_period_end=false, which terminates at the provider and locally in one step. Note that this does not itself refund anything.
Changing your mind is an operator action: extending a cancelling subscription's expiry reactivates it and clears cancelled_at.
Cancellation does not tear the tenant down. Teardown is a separate, deliberately slow timeline (below) — so a customer who resubscribes gets their data back intact.
The grace → suspend → destroy timeline
A daily platform job walks subscriptions that are cancelled or expired and enforces a staged policy. The clock starts when access ended — for a cancellation served out to period end that is expires_at, not the earlier moment the customer asked. Cancelling early therefore costs the customer nothing: cancelling subscriptions are not walked at all, and the grace window begins once the period is over.
| Stage | Default timing | What happens |
|---|---|---|
| Grace | Day 0 | Notice email to the org; nothing else changes |
| Suspend | After 14 days | Instance state and lifecycle delivery intents are committed; the delivery worker invalidates caches and sends suspend; suspension notice sent |
| Destroy | After 90 days | Instances are deprovisioned — only if destruction is explicitly enabled; it is off by default |
Each stage emits cp.subscription.lifecycle_enforced with payload {subscription_guid, status, stage} (stage ∈ grace | suspend | destroy). At the suspend stage, the Control Plane atomically commits each newly suspended instance and its delivery intent. A PlatformJob worker later invalidates the suspension cache, resolves that instance's organization-scoped endpoint, and sends suspend. The durable job deduplicates enqueue attempts, preserves per-instance suspend/resume order, fences concurrent workers, and retries delivery; a non-converging job becomes visible to operators. The event remains the subscription-level signal for consumers that need billing-specific messaging or orchestration. The destroy stage runs the same fail-closed deprovision contract as any other teardown (see Provisioning).
This billing trigger is distinct from an operator suspending one instance directly. Operator suspend/unsuspend actions commit the same kind of per-instance delivery intent, but they do not represent the grace-period policy described here.
The timings are policy, not constants: operators can tune them platform-wide, per plan, or per organization.
Reactivation
If the subscription comes back — a late payment lands, or the customer resubscribes — the platform clears the billing suspension on the org's instances and sends a "service restored" notice. The payment/reactivation path does this without waiting for the next daily sweep; the sweep also reconciles any active or trial subscription whose instances remain billing-suspended. Both paths commit the cleared state and a durable resume intent together. The Control Plane's API state is authoritative and visible on commit. Request-gate enforcement and the app handler converge asynchronously after the worker invalidates the suspension cache, or after the cached entry's TTL expires if invalidation is delayed.
Suspend and resume delivery is at least once. Retries reuse a stable request identity, so your handlers must be idempotent for that instance and identity rather than treating every HTTP attempt as a new transition.
Renewal
Renewals are driven by the payment provider (Stripe), not by the platform:
- On a successful renewal charge, the provider's webhook lands in the Control Plane, which mirrors the invoice into the platform's invoice table (the same one
listInvoices()reads) and emitscp.invoice.paid— payload includesamount_paid_cents,currency,paid_at, andbilling_reason, which distinguishes a renewal (subscription_cycle) from a first charge. - The subscription's period end (
expires_at) is refreshed from the provider's subscription-updated webhook as part of the same delivery cycle.
There is deliberately no separate subscription.renewed event — consume cp.invoice.paid and branch on billing_reason if your app needs to react to renewals.
Subscriptions on plans without a payment provider (manual/bank-transfer billing) follow a parallel track: a platform job issues an open invoice each billing period, idempotent per period, and the invoice lifecycle (draft → open → paid | past_due | void | uncollectible) is managed by operators.
Payment failure
When a renewal charge fails:
- The provider's failure webhook reaches the Control Plane, which marks the mirrored invoice
past_dueand emitscp.invoice.failed— payload includesattempt_count,next_payment_attempt_at, and the carddecline_code, relayed from the provider so your app can render precise messaging ("we'll retry on Friday"). If the decline was an expired card,cp.payment_method.expiredis emitted as well. - The provider drives the retries on its own schedule (Stripe smart retries). The platform does not run its own dunning counter.
- When the provider marks the subscription past due, the platform syncs that status — and gate 3 starts returning 402
payment_past_duewith theupdate_paymentaction. - If the provider exhausts its retries and cancels, the subscription becomes
cancelledand enters the grace → suspend → destroy timeline above. - If a retry eventually succeeds, the status returns to
active, any billing suspension is lifted, and the gate passes again — nothing for your app to do.
Trials
- A trial starts without a card (
startTrial()— see Sign-up & onboarding); the subscription istrialing, which passes gate 3, and its length is the trial plan'strial_dayssetting (30 days on the standard Trial plan), editable in the Control Plane Plans page. A trial plan with notrial_daysgrants no trial. - As the end approaches, the platform emits
cp.subscription.trial_expiringat 7, 3, and 1 days out — the signal for in-app upgrade prompts. - After
expires_atpasses, the next daily lifecycle tick changes an unconverted trial toexpired. Gate 3 then returns 402subscription_expiredwith therenewaction. This expiry is a billing invariant: disabling infrastructure suspension/destruction does not extend the trial. cp.subscription.trial_endedis emitted transactionally with that status change and carriesoutcome: expired_without_conversion. The same event usesconverted_to_paidorcancelled_during_trialfor those conclusions. Conversion happens through a normal checkout; the paid subscription takes over from the trial.
Plan changes
changeSubscription() (upgrade, downgrade, or interval change) recomputes the org's entitlements server-side immediately: the feature and quota gates read live plan data, so the next request is evaluated against the new plan — no propagation step in your app. What your app must do is react to cp.subscription.plan_changed: recompute anything you cached or derived from features and quotas. Declare it in spec.events.consumes like any other platform event (see Events).
The billing events your app can consume
All of these are declared in the Control Plane's manifest, are tenant-private, and are webhook-subscribable — consume them over Kafka or as signed webhooks (see Events):
| Event | When |
|---|---|
cp.subscription.created | A subscription is created |
cp.subscription.trial_started | A no-card trial starts |
cp.subscription.trial_expiring | 7 / 3 / 1 days before trial end |
cp.subscription.trial_ended | Trial concludes (with outcome) |
cp.subscription.plan_changed | Plan or interval changed — recompute your feature/quota caches |
cp.subscription.cancelled | Cancellation requested — read effective_at for when access actually ends, which may be in the future |
cp.subscription.lifecycle_enforced | A grace / suspend / destroy stage ran for a lapsed subscription |
cp.invoice.created / cp.invoice.paid / cp.invoice.failed | Invoice lifecycle — paid with billing_reason: subscription_cycle is your renewal signal; failed carries retry timing and decline codes |
cp.payment_method.added / removed / expired | Card lifecycle on the org |
cp.refund.completed | A refund was processed |
Two deliberate absences: there is no subscription.renewed (use cp.invoice.paid) and no subscription.past_due event (use cp.invoice.failed, which carries strictly more information).
One-off payments
Separate from subscriptions, apps can sell one-off products (credit packs, add-ons) declared in their manifest. When the checkout completes — and only once the payment is actually settled, so delayed payment methods don't grant early — the Control Plane POSTs to the product's declared fulfillment.handler_endpoint on your app (internal tier) with {org_guid, payment_ref, ...}. Your app owns the ledger; deduplicate on payment_ref, because provider webhooks can redeliver.
When a product declares resource.component_type, CP first provisions a generic tenant binding: CP's durable record that links an organization, app instance, component type, and provisioned resource. CP then includes a resource_claim, the generation-specific grant or revoke request that the provider app must acknowledge. The binding records pending, failed, claimed, revoked, or checkout-cancelled state durably. Operators can inspect its claim_status, claim_reason, and claim_attempts; a failed or malformed acknowledgement leaves a retryable record instead of only a lifecycle warning. Each binding owns one ordered grant/revoke lifecycle. A new grant cannot replace an unresolved or active grant; repeat purchases that do not own infrastructure must omit resource and remain app-ledger products.
For resource-backed one-time purchases, CP reserves the exact binding generation before exposing the hosted payment page and uses that reservation as the provider idempotency key. Ambiguous transport/API failures keep the reservation open so the same checkout can be recovered. Definitive rejection or a signed failed/expired event cancels only that exact reservation. Once a signed event confirms asynchronous settlement is underway, no local opening deadline may cancel it. Teardown refuses while a live checkout reservation exists, so a paid session cannot outlive the resource it names.
Where this fits
- Sell & manage subscriptions — the client calls that trigger everything on this page.
- Plans, features & quotas — note that quota suspension (a plan's
suspendquota policy) is a different mechanism from the billing-lifecycle suspension described here: one is "you exceeded a limit", the other is "you stopped paying". - Provisioning — the fail-closed deprovision contract the destroy stage invokes.
- Events — envelopes, consuming, webhook signing for every event named above.