Lifecycle and States
How a subscription moves through its life. Understand the state machine, the renewal cron, and the dunning policy before you wire your webhook handlers.
Billing intervals
Throttle supports four intervals out of the box: weekly, monthly, quarterly, and yearly. The interval is set on the subscription at create time (via the recurring block on the checkout session, or directly on POST /api/v1/subscriptions) and drives the period math the renewal cron uses.
- Period advancement uses calendar arithmetic with day-of-month clamping: a Jan 31 subscription rolls to Feb 28 (or 29 in leap years) on a monthly interval, then back to Mar 31 the next cycle. No drift over multi-year subscriptions.
- Mixed-interval pricing (e.g., Pro Monthly + Pro Yearly with a 20% discount on yearly) is your job — you store the two plans in your own catalog and pass the right
amount/intervalto Throttle. We don't model plan tiers; see Creating Subscriptions for why. - Plan changes can swap the interval on an existing subscription (e.g., monthly → yearly) via
PATCH /api/v1/subscriptions/:id. The new interval applies on the next renewal — no proration credit is issued for the unused portion of the current period.
States
trialing— Created withtrialEndin the future. No charge has been attempted. Card is already vaulted.active— Most subscriptions live here. Renews on the period boundary.paused— Manually paused. Skipped by the renewal cron. Can resume back to active.past_due— Three or more renewal attempts have failed. Still being retried, but no further attempts will succeed without a new card.cancelled— Terminal. Reached via explicit cancel, dunning exhaustion, or merchant action. Renewals stop.
State diagram
stateDiagram-v2 [*] --> trialing: create with trialEnd [*] --> active: create without trial trialing --> active: trialEnd cron tick trialing --> cancelled: cancel() active --> paused: pause() paused --> active: resume() paused --> cancelled: cancel() active --> active: renewal succeeds / period advances active --> active: renewal fails 1-2 times active --> past_due: third renewal failure active --> cancelled: cancel() past_due --> active: payment recovers past_due --> cancelled: next failure / dunning exhausted past_due --> cancelled: cancel() cancelled --> [*]
| From | Trigger | To | What Throttle does |
|---|---|---|---|
trialing | trialEnd cron tick | active | Activates the subscription and emits subscription.activated without charging. |
active | renewal succeeds | active | Advances the current period and emits subscription.renewed. |
active | first or second renewal failure | active | Records the failure, schedules the next retry, and emits subscription.payment_failed. |
active | third renewal failure | past_due | Marks the subscription past_due and emits subscription.past_due. |
past_due | next renewal failure | cancelled | Cancels with reason dunning_exhausted and emits subscription.cancelled. |
| Any non-terminal state | cancel() | cancelled | Stops future renewals and emits subscription.cancelled. |
Trial behavior
Trials always require a vaulted card. The embed collects and vaults the card at signup; the cron simply waits until trialEnd passes before attempting the first charge.
- On
trialEnd, the cron flips the row fromtrialingtoactiveand emitssubscription.activated. - The first paid period begins immediately after — no second tick is needed.
- If the buyer cancels during the trial, the subscription transitions directly to
cancelledwith no charge.
subscription.trial_blocked webhook fires. There's also a pre-flight POST /api/v1/subscriptions/eligibility-check endpoint for explicit gating. See Trial Fraud Protection for the full guide, including what this does NOT cover (different cards, disposable emails, IP velocity).Renewal
A cron runs every 5 minutes selecting subscriptions whose currentPeriodEnd has passed. For each one it calls the provider's chargeStored with an idempotency key derived from the subscription ID and the period start, then advances the period on success.
// Renewal job shape.
// Throttle evaluates due subscriptions on a recurring schedule.
for (const sub of dueSubscriptions) {
if (sub.status === 'trialing' && sub.trialEnd <= now) {
await subscriptionService.renew(...); // flips trialing → active, no charge
continue;
}
if (sub.lastPaymentAt >= sub.currentPeriodStart) continue; // idempotency guard
const result = await paymentProvider.chargeStored({
paymentMethodId: vault.processorToken,
buyerId: vault.providerBuyerId,
amount: sub.amount,
idempotencyKey: `${sub.id}:${sub.currentPeriodStart.toISOString()}`,
});
if (result.success) {
await subscriptionService.renew(sub, newPeriodStart, newPeriodEnd);
} else {
await subscriptionService.recordPaymentFailure(sub.id);
// schedules next attempt at currentPeriodEnd + RETRY_DAYS[failureCount - 1]
}
}Two guards prevent double-billing: a DB check (lastPaymentAt >= currentPeriodStart) and the provider-level idempotency key. Either alone would not be enough.
Dunning
When a renewal charge fails, the cron records the failure and schedules the next attempt. The schedule is fixed:
| Attempt | Retry timing | Resulting state | Developer action |
|---|---|---|---|
| 1st | +1 day | failureCount=1, status unchanged | Send the first payment-failed notice. |
| 2nd | +3 days | failureCount=2, status unchanged | Escalate the copy; keep access policy under your control. |
| 3rd | +7 days | past_due | Send the final-warning notice and surface update-card UI. |
| 4th+ | No further schedule | cancelled | De-provision or downgrade according to your offboarding policy. |
On every failed attempt, Throttle emits subscription.payment_failed. The first time a subscription transitions to past_due, it additionally emits subscription.past_due. When dunning is exhausted, it transitions to cancelled with reason: 'dunning_exhausted'.
subscription.payment_failed and use your own email provider (Resend, Postmark, SES) to send the dunning notice. The <DunningBanner /> component in the React package shows an in-app message; emails are still your job.Grace period — your call, not Throttle's
When a subscription enters past_due, it stays there until either the next charge succeeds or dunning is exhausted. Whether the buyer keeps access to your service in the meantime is your decision — Throttle's status flag is a signal, not an enforcement mechanism.
The conventional pattern: keep service active during past_due for the dunning window (effectively a 7-day grace), then cut off when the subscription transitions to cancelled.
Plan changes
POST /api/v1/subscriptions/:id/change-plan is the dedicated endpoint for mid-cycle plan changes. It replaces the older PATCH /api/v1/subscriptions/:id pattern for plan swaps and adds support for the two distinct upgrade and downgrade paths.
Upgrades — effective: "now"
When a buyer moves to a higher-tier or more expensive plan, charge them immediately so they gain access to the new features right away. With effective: "now":
- The stored card is charged the full new plan amount via the existing vaulted payment method.
- The billing period resets from now — the next renewal date is
now + intervalof the new plan. - The plan fields (
planReference,planName,interval,amount) are updated immediately. - Any existing
pending*fields are cleared. subscription.plan_changedfires.
There is no mid-period proration credit for the remainder of the old period. If you need to credit the buyer for unused time, apply a manual discount or credit through your own accounting system.
402 payment_failed and no changes are made to the subscription. Surface the failure to the buyer and let them update their payment method before retrying.Downgrades — effective: "period_end"
When a buyer moves to a lower-tier or cheaper plan, it is typically better to let them finish the period they paid for before switching. With effective: "period_end":
- No charge is made immediately. The current plan and billing continue unchanged.
- The four
pending*fields are written to the subscription row:pendingPlanReference,pendingPlanName,pendingInterval, andpendingAmount. subscription.plan_change_scheduledfires so your handler can notify the buyer.- On the next renewal, the renewal cron reads the
pending*fields, swaps the plan, clears them, and emitssubscription.plan_changed.
Cancelling a pending change
Call DELETE /api/v1/subscriptions/:id/pending-change to clear all four pending* fields and keep the subscription on its current plan. This is a no-op if no pending change exists.
A second change-plan call overwrites the existing pending* fields — you do not need to clear first before scheduling a different downgrade.
Precedence vs. cancel-at-period-end
If a subscription has both cancelAtPeriodEnd: true and a pending plan change, the cancellation takes precedence. The subscription will cancel at the period end and the pending change will never apply. To prevent this, first unset the cancel flag: PATCH /api/v1/subscriptions/:id with { cancelAtPeriodEnd: false }, then schedule the plan change.
Next
- Managing Subscriptions — pause, resume, cancel, change plan.
- Subscription Webhooks — every event your handler will see.