Skip to main content

Documentation

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:

StatusMeaningGate 3 (require_active_subscription)
trialingIn a free trial started without a cardPasses
activePaid and currentPasses
past_dueA renewal charge failed; the provider is retryingPasses during the payment grace window (reason payment_grace, with an update_payment banner); after it, 402 — payment_past_due
cancellingCancellation requested, but the period already paid for has not ended yetPasses
cancelledCancelled, and the paid period has ended402 — subscription_cancelled
expiredReached its end without renewal402 — 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:

json
{
  "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:

  1. 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.)
  2. Marks the subscription cancelling and stamps cancelled_at with the moment of the request. expires_at holds 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.
  3. Emits cp.subscription.cancelled — your app's signal to react. Read effective_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.
  4. Closes out to cancelled when the period ends — whichever comes first, the provider's deletion webhook or the daily lifecycle sweep. From that moment gate 3 returns 402 subscription_cancelled. Only one cp.subscription.cancelled is 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.

StageDefault timingWhat happens
GraceDay 0Notice email to the org; nothing else changes
SuspendAfter 14 daysInstance state and lifecycle delivery intents are committed; the delivery worker invalidates caches and sends suspend; suspension notice sent
DestroyAfter 90 daysInstances 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 emits cp.invoice.paid — payload includes amount_paid_cents, currency, paid_at, and billing_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:

  1. The provider's failure webhook reaches the Control Plane, which marks the mirrored invoice past_due and emits cp.invoice.failed — payload includes attempt_count, next_payment_attempt_at, and the card decline_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.expired is emitted as well.
  2. The provider drives the retries on its own schedule (Stripe smart retries). The platform does not run its own dunning counter.
  3. When the provider marks the subscription past due, the platform syncs that status — and gate 3 starts returning 402 payment_past_due with the update_payment action.
  4. If the provider exhausts its retries and cancels, the subscription becomes cancelled and enters the grace → suspend → destroy timeline above.
  5. 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 is trialing, which passes gate 3, and its length is the trial plan's trial_days setting (30 days on the standard Trial plan), editable in the Control Plane Plans page. A trial plan with no trial_days grants no trial.
  • As the end approaches, the platform emits cp.subscription.trial_expiring at 7, 3, and 1 days out — the signal for in-app upgrade prompts.
  • After expires_at passes, the next daily lifecycle tick changes an unconverted trial to expired. Gate 3 then returns 402 subscription_expired with the renew action. This expiry is a billing invariant: disabling infrastructure suspension/destruction does not extend the trial.
  • cp.subscription.trial_ended is emitted transactionally with that status change and carries outcome: expired_without_conversion. The same event uses converted_to_paid or cancelled_during_trial for 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):

EventWhen
cp.subscription.createdA subscription is created
cp.subscription.trial_startedA no-card trial starts
cp.subscription.trial_expiring7 / 3 / 1 days before trial end
cp.subscription.trial_endedTrial concludes (with outcome)
cp.subscription.plan_changedPlan or interval changed — recompute your feature/quota caches
cp.subscription.cancelledCancellation requested — read effective_at for when access actually ends, which may be in the future
cp.subscription.lifecycle_enforcedA grace / suspend / destroy stage ran for a lapsed subscription
cp.invoice.created / cp.invoice.paid / cp.invoice.failedInvoice lifecycle — paid with billing_reason: subscription_cycle is your renewal signal; failed carries retry timing and decline codes
cp.payment_method.added / removed / expiredCard lifecycle on the org
cp.refund.completedA 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 suspend quota 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.