StrimzStrimz
Subscriptions

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 after approve(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:

  1. Writes a SubscriptionCharge row with status: 'failed' and the outcome.
  2. Fires subscription.charge_failed.
  3. Flips the subscription to at_risk and 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.

await strimz.subscriptions.cancel({ id: 'cmq4wck6a0002je6lfmb5bgjp', reason: 'customer request' })

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 → active and at_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.

On this page