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 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 / interval to 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 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 — 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 --> [*]
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.
activefirst or second renewal failureactiveRecords the failure, schedules the next retry, and emits subscription.payment_failed.
activethird renewal failurepast_dueMarks the subscription past_due and emits subscription.past_due.
past_duenext renewal failurecancelledCancels with reason dunning_exhausted and emits subscription.cancelled.
Any non-terminal statecancel()cancelledStops 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 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. The auto-create flow checks the card's fingerprint against prior subscriptions; on a match the new subscription is created without a trial and a 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.

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.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:

AttemptRetry timingResulting stateDeveloper action
1st+1 dayfailureCount=1, status unchangedSend the first payment-failed notice.
2nd+3 daysfailureCount=2, status unchangedEscalate the copy; keep access policy under your control.
3rd+7 dayspast_dueSend the final-warning notice and surface update-card UI.
4th+No further schedulecancelledDe-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'.

No throttle-sent emails
Throttle does not send emails to buyers. Subscribe to 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 + 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.

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.

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 four pending* fields are written to the subscription row: pendingPlanReference, pendingPlanName, pendingInterval, 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 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