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 bills on any cadence from 1 hour to 1 year, not only the five named intervals
(weekly, biweekly, monthly, quarterly, yearly). Send a named interval, or intervalUnit (hour, day, week, or month) + intervalCount, on
the checkout session's recurring block, on a cart or
quote line's recurring block, on POST /api/v1/subscriptions, or on change-plan. The server stores one canonical form — every 14
days comes back as biweekly, every 24 hours as custom / day / 1 — and every response carries interval (the name or "custom"), intervalUnit, and intervalCount. An invalid cadence is 400 invalid_interval; a named interval that disagrees with
the unit and count is 400 interval_conflict.
- Periods are anchored, not chained. Each period end is computed from
billingAnchorAt. Month cadences keep the anchor's day of month, clamped to short months: a Jan-31 monthly subscription renews Feb 28 (29 in a leap year), then Mar 31. Hour, day, and week cadences advance by exact durations. The anchor moves when billing restarts — trial conversion, a recovered payment, an immediate plan change or a scheduled cadence change, or a resume, which shifts it by the time spent paused. -
biweeklymeans every 14 days — 26 charges a year, not twice a month. - 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 cadence via
POST /api/v1/subscriptions/:id/change-plan(rejected on an installment plan).
totalPayments.
Full guide: Custom Cadence → — every validation rule, the label helpers, the renewal-date algorithm, month-end clamping, hour cadences, trials, and cadence changes.
Installment plans
Set totalPayments (2–60) on the checkout session's recurring block, on a cart or quote line's recurring block, or on POST /api/v1/subscriptions to make a subscription an
installment plan — a fixed number of payments that ends itself. The paid signup is payment 1
of N, and the plan becomes completed the moment payment N
succeeds, emitting subscription.completed and a paid-in-full
email. Merchants who gate access on status =
active must treat completed as paid in full.
totalPayments is set at creation only (
400 total_payments_immutable), an installment plan cannot
have a trial (400 invalid_combination), and its amount,
cadence, quantity, and plan are fixed (409
installment_plan_locked). Only the merchant can pause, resume, or cancel it; buyer
storefront sessions get 403 installment_plan_merchant_only.
Payments can also differ: a custom schedule gives each
payment its own amount and gap. Its later due dates are estimates: a late payment that
succeeds, a pause, or a waive moves them, and nothing is skipped. A manual period renew of a
scheduled plan is refused (409 installment_plan_locked).
Full guide: Installment Plans → — how payments are counted, the lock and completed-state matrices, failed installments, cancelling and refunding, webhooks, and SDK examples.
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— A renewal charge failed. The subscription moves here on the first failed attempt and stays while Throttle retries (up to three retries; see Dunning below). While it is past due,currentPeriodEndholds the time of the next scheduled retry, not the date the period was due. The buyer's renewal-failed email carries an Update your card button to a Throttle-hosted page (checkout.usethrottle.dev/billing/{token}) where they save a new card and the past-due period is charged on the spot; a successful charge returns the subscription toactiveand emitssubscription.renewedlike any other renewal. The link is signed for that customer and subscription and valid for 14 days. See Payment Methods → Subscription self-service for the routes behind it. -
cancelled— Terminal. Reached via explicit cancel, dunning exhaustion, or merchant action. Renewals stop. -
completed— Terminal. Reached only fromactive, when an installment plan's final payment succeeds. Renewals stop; pause, resume, cancel, and plan changes are rejected likecancelled. See Installment plans above. -
unpaid— Accepted by the status schema and the published SDKs for forward compatibility. The customer-facing renewal engine documented on this page does not transition a subscription to it today.
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 --> past_due: renewal charge fails past_due --> past_due: retry 1 or 2 fails active --> cancelled: cancel() active --> completed: final installment payment succeeds past_due --> active: payment recovers past_due --> cancelled: retry 3 fails / dunning exhausted past_due --> cancelled: cancel() cancelled --> [*] completed --> [*]
| 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 | renewal charge fails (attempt 1) | past_due | Records failureCount 1, schedules retry 1, and emits subscription.payment_failed and subscription.past_due. |
past_due | retry 1 or retry 2 fails (attempt 2 or 3) | past_due | Records the failure, schedules the next retry, and emits subscription.payment_failed. |
past_due | a retry succeeds | active | Clears failureCount, opens a new period from the retry, and emits subscription.renewed. |
past_due | retry 3 fails (attempt 4) | cancelled | Cancels with reason dunning_exhausted and emits subscription.cancelled. |
| Any non-terminal state | cancel() | cancelled | Stops future renewals and emits subscription.cancelled. |
active | final installment payment succeeds | completed | Sets completedAt, schedules no further renewal, and emits subscription.completed. |
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.
409 trial_not_available (no order, no subscription, no
free first period) and a subscription.trial_blocked webhook
fires with subscriptionId: null. If two checkouts race with
the same card, the subscription is created trialing with
its trial ending at once, so the renewal job charges the first period on its next run, and
the webhook carries its id.
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.currentPeriodEnd) 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 + dunningRetryDelay(cadence, failureCount)
}
}
Two guards prevent double-billing: a DB check (
lastPaymentAt >= currentPeriodEnd) and the
provider-level idempotency key. Either alone would not be enough.
Which card a renewal charges
Each subscription stores the saved card it renews on as paymentMethodId: the card the buyer paid with at checkout.
A checkout that creates several subscriptions stores the same card on each. null means the subscription renews on the customer's
default card, as every subscription did before 2026-10-05. Renewals, trial conversion,
dunning retries, both retry-charge routes and immediate plan changes all use it.
- Order. The subscription's own card first; if it is declined, the customer's default card; if the subscription's card is the default, or the default cannot be charged, the newest other usable card. A card that is inactive, removed, or not vaulted for merchant-initiated charges is skipped. A subscription with no card of its own tries the default, then the newest other card, exactly as before.
- A second card only after a real decline. Another card is charged only
when the payment provider answered that the first charge was declined. A charge with no
final answer (still in flight, a transport error, an unknown status, a thrown call) is
never followed by a second charge, whether or not another card exists: the renewal records
a
system_errorwith reasoncharge_unverified(below) and enters no dunning, and later renewal runs read that charge's current transaction state at the payment provider instead of charging again: a capture is recorded and the period advances, a decline is handled as a decline, and a charge still in flight is waited on. One that is still unresolved after about 12 hours moves the subscription to a blockingpayment_reviewstate: no further charges, and the merchant is alerted. - A card that can no longer be used is replaced. When the subscription's
own card hard-declines (expired, an invalid card number, lost or stolen) and another card pays,
the subscription moves to the card that paid.
subscription.backup_pm_usedcarriesrepinned: true, and the buyer is told "We charged your other card and will use it from now on". A soft decline (insufficient funds, do not honour) keeps the subscription on its card. - Merchant-initiated flags. A renewal, trial conversion, dunning retry or
retry-charge on the subscription's own checkout card is sent to the payment provider
as a merchant-initiated recurring charge (
merchantInitiated,paymentSource: recurring,isSubsequentPayment). A charge on another card (the default as a fallback, a card chosen later through the billing link, a card moved there by a new default) and plan-change charges keep the flags they had before. - Changing the card. The card for one subscription can be picked by the
merchant with
PATCH /api/v1/subscriptions/:id/payment-method(also Change card in the dashboard andsetPaymentMethod()in@usethrottle/subscriptions), or by the buyer withPATCH /v1/storefront/me/subscriptions/:id/payment-methodorPATCH /api/v1/me/subscriptions/:id/payment-method, which the hosted billing link uses. Only that subscription moves, and nothing is charged until its next renewal or dunning retry. Setting a new default card, by the buyer or the merchant, moves the subscriptions that were on the previous default; subscriptions on another card stay where they are. Both emitsubscription.updated. - Deleting the card. Allowed: the subscription falls back to the default
card, and no
subscription.updatedis sent. Deleting a card that is also the default while the customer has an active subscription is still refused with409. - Invoices print the card that was actually charged.
Dunning
A failed renewal gets up to three retries: four charge attempts in all. The first failed
attempt moves the subscription to past_due; the fourth
cancels it with reason: 'dunning_exhausted'. Retry n (1, 2, 3) is scheduled min(n × cadence, [1 day, 3 days, 7 days][n − 1]) after
the attempt that just failed — a cadence shorter than a week retries proportionally faster;
weekly and longer land on 1 / 3 / 7 days exactly. A month counts as at least 28 days for
this formula, so every month-based cadence always hits the cap. The renewal job runs every
5 minutes, so a retry is made within a few minutes of its scheduled time.
| Cadence | Retry 1 | Retry 2 | Retry 3 |
|---|---|---|---|
| every 1 hour | +1 h | +2 h | +3 h |
| every 12 hours | +12 h | +1 d | +1.5 d |
| every 2 days | +1 d | +3 d | +6 d |
| weekly, biweekly, every 10 days | +1 d | +3 d | +7 d |
| monthly and longer | +1 d | +3 d | +7 d |
Delays are measured from the previous failed attempt, so on a weekly-or-longer cadence the retries land about 1, 4, and 11 days after the first failure, and on an hourly cadence about 1, 3, and 6 hours after it.
| Attempt | Resulting state | Events | Developer action |
|---|---|---|---|
| 1st — the renewal charge | past_due, failureCount 1, retry 1 scheduled | subscription.payment_failed (attempt 1) and subscription.past_due | Send the first payment-failed notice and surface update-card UI. |
| 2nd — retry 1 | past_due, failureCount 2, retry 2 scheduled | subscription.payment_failed (attempt 2) | Escalate the copy; keep access policy under your control. |
| 3rd — retry 2 | past_due, failureCount 3, retry 3 scheduled | subscription.payment_failed (attempt 3) | Send the final warning: one attempt is left. |
| 4th — retry 3 | cancelled, reason dunning_exhausted | subscription.cancelled | De-provision or downgrade according to your offboarding policy. |
Each of the first three failed attempts emits subscription.payment_failed with attempt, nextRetryAt, and lastError. The first one also emits subscription.past_due, once per run of failures. The fourth
emits only subscription.cancelled with reason: 'dunning_exhausted'. A retry that succeeds —
from the renewal job, retry-charge, or the buyer updating
their card — returns the subscription to active, clears failureCount, and emits subscription.renewed. A manual retry-charge that fails uses up the next attempt, exactly
like a scheduled one. When the subscription's card declines and another of the
buyer's cards pays (see Which
card a renewal charges), no attempt is used.
<DunningBanner /> component in the React
package additionally shows an in-app message during dunning.
When a renewal can't complete
These renewal issues are distinct from a declined card. None is part of the dunning ladder above.
- System errors. The renewal threw before it could charge, the
tax quote failed, or the subscription's billing period did not advance after its last
payment (
period_not_advanced: a payment is recorded after the period's end, so the renewal is skipped rather than charged twice), or the renewal failed internally. These system faults count consecutive failures and send one in-app admin notification and one Sentry error at the third. Status and dunning do not change. A successful renewal, a definitive card decline or cancellation clears a stale system fault; it never clears a retained pending charge. - Processor configuration errors. A request rejected without creating a
transaction records
system_errorwith reasonconfig_error. The merchant is alerted immediately: "Your payment processor rejected the renewal request — check the connector's credentials/configuration." Renewals retry after an hour, without trying another card or putting the buyer into dunning. Immediate plan and quantity changes return402 payment_failedwith messageprocessor_config_errorfor this rejection. - A charge awaiting confirmation. A charge still in flight, a lost response
or an unreadable outcome records
system_errorwith reasoncharge_unverified. No other card is charged and dunning does not start. Later runs read the transaction's current state: a confirmed capture is recorded, a definitive decline is handled as a decline, and an uncertain outcome waits. If no transaction can be found, only the identical frozen request can be resent with its original key before the 12-hour review deadline. - Payment review. At 12 hours, an unresolved charge becomes
payment_reviewwith reasoncharge_unresolved. Further charges stop and the merchant receives an alert. Immediate cancellation also promotes a pending charge to review: cancellation does not refund or settle it. Retry-charge, waive-period and immediate changes return409 renewal_needs_attentionuntil support resolves the charge. While a plan-change charge is pending, retrying that same change can reconcile and apply it; the background watcher alone does not apply the change. A captured but unapplied change also becomes review at 12 hours, even if the next renewal is not due yet. Scheduled changes and cancellation remain available. - Orphan charges. The card was charged but the renewal could not be
recorded — a failure after the charge succeeded but before the period-advance update. This
is alerted immediately (no threshold), and every renewal path pauses on this
subscription: the renewal cron skips it, the renewal lease refuses it, and merchant
retry-charge, merchantwaive-period, an immediate (effective: "now")change-planorchange-quantity, and the buyer's own retry-charge all return409 renewal_needs_attention. Scheduled (effective: "period_end") changes and cancellation are still accepted, and a scheduled cancellation still executes at its period end — it charges nothing, so there is no reason to hold it. The orphan issue itself is left on the row after the cancel: staff must still record the stuck charge. A failure after the renewal was recorded — a webhook emit failing, for example — does not create an orphan; it is reported to Sentry only, because the subscription and invoice are already correct.
These issues surface as renewalIssue on the subscription
response — read-only, never settable on a request (a PATCH
that includes it is 400) and absent from the buyer-facing
storefront response. message is a fixed sentence for reason, never the underlying error text. Shape:
{
kind: 'system_error' | 'orphan_charge' | 'payment_review',
// system_error: first five reasons; orphan_charge: record_failed; payment_review: charge_unresolved
reason: 'tax_quote_failed' | 'internal_error' | 'period_not_advanced' | 'charge_unverified' | 'config_error' | 'record_failed' | 'charge_unresolved',
count: number,
firstAt: string,
lastAt: string,
message: string,
alertedAt: string | null,
charge?: {
transactionId: string | null,
amount: number,
currency: string,
periodStart: string, // stored charge/cycle context, not a new renewal instruction
},
} Renewal reminder
The pre-renewal reminder (sent 3 days ahead) only makes sense for cadences long enough for a 3-day warning to land before the charge: it goes out only when the cadence is 7 days or longer. Weekly-and-longer cadences behave exactly as before; an hourly, daily, or other sub-week custom cadence never gets one.
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 (about 11 days from the
first failure on a weekly-or-longer cadence), 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.
409 installment_plan_locked
when totalPayments is set. Cancel it and create a new one
to change the terms.
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 new plan amount minus the credit for the unused paid time of the current period (see Credits and refunds below), via the existing vaulted payment method. A change the credit covers in full charges nothing.
-
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.
The response carries a proration object (
creditCents, chargedCents, fullAmount). A subscription that is past_due is charged in full (no paid time to credit) and
a successful charge returns it to active; a trialing subscription is charged nothing and keeps its
trial. An immediate change to the exact current terms is 400 invalid_subscription_state. Full details:
Managing Subscriptions
.
Because the period resets, any usage already reported against it (see Usage Reporting) moves
forward with it: it is stamped with the new period start in the same write, so it is billed
at the next renewal instead of being lost. The same applies to an immediate change-quantity.
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
pending*fields are written to the subscription row:pendingPlanReference,pendingPlanName,pendingInterval,pendingIntervalUnit,pendingIntervalCount, 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 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
A cancel-at-period-end and a pending plan or seat change can never coexist on the
same subscription. Scheduling a change (
effective: "period_end") on a subscription whose cancelAtPeriodEnd is already true is refused with 409 invalid_state. To schedule a change instead of
letting the subscription cancel, first undo the cancellation with POST /api/v1/subscriptions/:id/resume, then
schedule the plan change.
Conversely, cancelling at period end (
POST /api/v1/subscriptions/:id/cancel with { atPeriodEnd: true }) on a subscription that
already has a pending change clears every pending* field in that same call — the response
reflects it immediately, not only once the boundary is reached. The pending change
never applies; the subscription cancels instead.
Credits and refunds
A plan change, a seat change, and a prorated cancel all work from what was actually paid for the current period, read off that period's subscription_invoices rows. They differ in one respect:
an immediate change credits the period's full value, a refund hands back cash only.
- Plan and seat changes (credit base). Cash paid for the current period
(net of refunds) plus the credit already applied on the current period's
invoice(s) (
creditAppliedCents), capped at the plan's base amount. So a second immediate change in the same period credits the whole period, not just the cash top-up. A change fully covered by credit records a$0paid invoice carrying the credit. - Prorated cancel (cash only).
refund: "prorated"onPOST /api/v1/subscriptions/:id/cancelrefunds the unused share of the cash paid for the current period, so it never refunds more than was collected. When nothing was paid for the current period — a waived or free period, or a period paid entirely by credit — the subscription is still cancelled, with a refund of0and no processor call, instead of refunding the plan price. -
A waived, free, or fully-refunded period has no value,
so it credits
0. - Exception: a subscription with no invoice history at all — created
before
subscription_invoicesexisted (June 2026) — keeps the old behavior and credits the plan price. A subscription billed since then that has invoice rows but none for its current period credits0and a prorated cancel refunds0. - Which states refund. An
activeorpast_duesubscription is refunded the unused paid time from now; apausedone frompausedAt(the buyer had no access after that). A refund on acancelledorcompletedsubscription is409 invalid_state.
Next
- Managing Subscriptions — pause, resume, cancel, change plan.
- Subscription Webhooks — every event your handler will see.