Subscriptions

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.
  • biweekly means 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 / interval to 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).
A subscription that falls behind is billed once, not caught up
An outage or a stalled retry can leave a subscription more than one period behind. Throttle charges it once, for whichever grid period contains the moment of the charge — the periods it missed are skipped, never billed back-to-back, and never counted toward an installment plan's 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 with trialEnd in 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, currentPeriodEnd holds 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 to active and emits subscription.renewed like 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 from active, when an installment plan's final payment succeeds. Renewals stop; pause, resume, cancel, and plan changes are rejected like cancelled. 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 --> [*]
Subscription state machine from creation through renewal, pause, dunning, and cancellation.
FromTriggerToWhat Throttle does
trialingtrialEnd cron tickactiveActivates the subscription and emits subscription.activated without charging.
activerenewal succeedsactiveAdvances the current period and emits subscription.renewed.
activerenewal charge fails (attempt 1)past_dueRecords failureCount 1, schedules retry 1, and emits subscription.payment_failed and subscription.past_due.
past_dueretry 1 or retry 2 fails (attempt 2 or 3)past_dueRecords the failure, schedules the next retry, and emits subscription.payment_failed.
past_duea retry succeedsactiveClears failureCount, opens a new period from the retry, and emits subscription.renewed.
past_dueretry 3 fails (attempt 4)cancelledCancels with reason dunning_exhausted and emits subscription.cancelled.
Any non-terminal statecancel()cancelledStops future renewals and emits subscription.cancelled.
activefinal installment payment succeedscompletedSets 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 from trialing to active and emits subscription.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 cancelled with no charge.
No 'free trial without card' in v1
Throttle does not yet support trials with no card on file. The card-up-front model is simpler and avoids a second flow to attach a card before trial end.
Trial-abuse protection ships in v1
Throttle automatically blocks the same physical card from claiming multiple trials on a single merchant. Checkout checks the card's fingerprint against prior subscriptions before any order exists; on a match the checkout is refused with 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.

ts
// 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_error with reason charge_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 blocking payment_review state: 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_used carries repinned: 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 and setPaymentMethod() in @usethrottle/subscriptions), or by the buyer with PATCH /v1/storefront/me/subscriptions/:id/payment-method or PATCH /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 emit subscription.updated.
  • Deleting the card. Allowed: the subscription falls back to the default card, and no subscription.updated is sent. Deleting a card that is also the default while the customer has an active subscription is still refused with 409.
  • 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.

CadenceRetry 1Retry 2Retry 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.

AttemptResulting stateEventsDeveloper action
1st — the renewal chargepast_due, failureCount 1, retry 1 scheduledsubscription.payment_failed (attempt 1) and subscription.past_dueSend the first payment-failed notice and surface update-card UI.
2nd — retry 1past_due, failureCount 2, retry 2 scheduledsubscription.payment_failed (attempt 2)Escalate the copy; keep access policy under your control.
3rd — retry 2past_due, failureCount 3, retry 3 scheduledsubscription.payment_failed (attempt 3)Send the final warning: one attempt is left.
4th — retry 3cancelled, reason dunning_exhaustedsubscription.cancelledDe-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.

Buyer emails Throttle sends
Throttle sends several transactional emails to buyers automatically, including a payment-failed notice on each of the first three failed attempts — you don't need to build your own for this. The full buyer-facing set: a welcome email when a subscription is created, a receipt on every successful renewal charge, a reminder a few days before renewal (cadences of a week or longer only), the payment-failed notice above, a cancellation email, a plan-changed and a plan-change-scheduled email, a backup-payment-method-used notice, and a paid-in-full email when an installment plan completes. The receipt states the amount actually charged, tax included, and the payment-failed notice states the amount the renewal tried to charge, tax included, on every plan. A payment that follows a gap of hours shows its time next to its date in the welcome, order-confirmation and reminder emails (labelled with the store's time zone, or UTC); payments after a gap of days, weeks or months show the date only, as before. The <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_error with reason config_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 return 402 payment_failed with message processor_config_error for this rejection.
  • A charge awaiting confirmation. A charge still in flight, a lost response or an unreadable outcome records system_error with reason charge_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_review with reason charge_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 return 409 renewal_needs_attention until 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, merchant waive-period, an immediate ( effective: "now") change-plan or change-quantity, and the buyer's own retry-charge all return 409 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:

ts
{
  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
  },
}
Orphan charges and payment reviews need support
An orphan charge or payment review does not resolve itself — the subscription stays blocked until Throttle support checks the current transaction state and reconciles the charge and subscription. If you do not want to keep that renewal, refund it afterwards through the normal subscription refund, so the refund shows in the ledger. Contact Throttle support to resolve one.

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.

Not on installment plans
An installment plan's amount, cadence, quantity, and plan are fixed at creation. Both paths below are rejected with 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 + interval of the new plan.
  • The plan fields (planReference, planName, interval, amount) are updated immediately.
  • Any existing pending* fields are cleared.
  • subscription.plan_changed fires.

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.

Charge failure returns 402
If the stored card declines during an immediate upgrade, the route returns 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, and pendingAmount.
  • subscription.plan_change_scheduled fires so your handler can notify the buyer.
  • On the next renewal, the renewal cron reads the pending* fields, swaps the plan, clears them, and emits subscription.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 $0 paid invoice carrying the credit.
  • Prorated cancel (cash only). refund: "prorated" on POST /api/v1/subscriptions/:id/cancel refunds 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 of 0 and 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_invoices existed (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 credits 0 and a prorated cancel refunds 0.
  • Which states refund. An active or past_due subscription is refunded the unused paid time from now; a paused one from pausedAt (the buyer had no access after that). A refund on a cancelled or completed subscription is 409 invalid_state.

Next