Subscription lifecycle
The statuses a subscription moves through, what moves it, and which webhook fires on each move.
A subscription is in one of these statuses at any given moment:
trialing: inside the free-trial window. Nothing has been charged yet.active: cycling normally. The last charge succeeded or the first one is due.at_risk: the last charge failed. Retries are scheduled.cancelled: cancelled. No more charges. Terminal.lapsed: the grace window ran out without a successful charge. Terminal.
The status enum also contains paused, but nothing sets it today. There is no pause or resume call.
Enrolment
Enrolment moves no money. The payer signs on the plan's checkout page, the relayer submits permitAndCreateSubscription(...), and the contract stores the subscription with nextChargeAt = startAt, or the current block time when there is no trial. The indexer projects the row and fires subscription.created.
Trial to active
If the plan has trialPeriodDays, a payer's first subscription to that plan starts in trialing, and nextChargeAt is set to the end of the trial. A payer who subscribed to the same plan before gets no second trial.
When the trial ends, the scheduler's sweeper picks the row up on its next run (every 15 minutes) and calls batchCharge(...). If the charge succeeds, the status flips to active and nextChargeAt moves forward by one interval. If it fails for a payment reason, the subscription moves to at_risk and follows the retry schedule.
No trial
Without a trial, the subscription starts in active with the first charge due at enrolment. The sweeper takes that charge on its next run, so expect up to about 15 minutes between subscription.created and the first subscription.charged.
Active to at_risk
A charge fails when the contract skips it with a payment failure outcome:
insufficient_funds: the payer's balance is below the charge amount.revoked_approval: the payer's allowance to the subscriptions contract is too low, for example afterapprove(strimzSubscriptions, 0).transfer_failed: the token transfer itself failed, for example because the token blocklisted the payer.merchant_inactive: the merchant is not active in the registry.
The contract does not revert. It emits SubscriptionChargeSkipped, and the indexer:
- Writes a
SubscriptionChargerow withstatus: 'failed'and the outcome. - Fires
subscription.charge_failed. - Flips the subscription to
at_riskand schedules the next retry.
Other outcomes, such as not_due or duplicate, are written for the audit trail but do not change the status or fire a webhook.
Retries
Retries back off: 15 minutes after the first failure, then 1 hour, 3 hours, 6 hours, 12 hours, then every 24 hours. About seven attempts fit in the 48-hour grace window.
Each failed retry fires subscription.charge_failed again and pushes the next retry further out. A retry that reuses the same attempt id updates its charge row and raises attemptNumber (see Recovery).
If a retry succeeds, the subscription returns to active, the retry counter resets, and the schedule continues. The failed SubscriptionCharge rows stay for audit.
At_risk to lapsed
The grace window is fixed at 48 hours after currentPeriodEndAt. An hourly scheduler cron moves every at_risk subscription past that point to lapsed and fires subscription.lapsed.
Lapsed is terminal. To come back, the payer subscribes again on the plan's checkout link. That creates a new subscription with a new id. A payer can only hold one live subscription per plan, so the old one has to be cancelled or lapsed first.
Active to cancelled
There is one kind of cancel, and it is immediate.
The row is cancelled at once and subscription.cancelled fires during the API call. The scheduler stops charging it. The payer can also cancel from their own wallet by calling cancel() on the contract; the indexer marks the row cancelled. See Cancellation.
What fires on each transition
- enrolment:
subscription.created - each successful charge, including
trialing → activeandat_risk → active:subscription.charged - each failed charge (
active → at_risk,at_risk → at_risk):subscription.charge_failed at_risk → lapsed:subscription.lapsed- API cancel:
subscription.cancelled cancelled → active, when Strimz restores a subscription its operator key cancelled by mistake:subscription.reactivated
The payloads are in Webhooks. Make your handler idempotent on event.id, since a delivery can arrive more than once.
What it doesn't model
No prorated upgrades. To switch plans, cancel the current subscription, subscribe to the new plan, and refund any difference.
No fixed-term subscriptions. Every subscription renews until it is cancelled or lapses. For "12 months then stop", your application calls subscriptions.cancel(...) at month 12.
No multi-currency plans. A plan is one currency. For EURC, create a EURC plan.

