Subscriptions

Managing Subscriptions

Pause, resume, cancel, change plans, and change the card. Same primitives whether you're calling from your backend, the dashboard, or the React package.

List and filter

GET /api/v1/subscriptions returns cursor-paginated results. Filter by status, interval, customerId, or externalCustomerId. Use q to search subscription ids, customer ids, plan fields, status, interval, or metadata.

Cancel

Two flavors. Pick based on your buyer experience. The backend examples use @usethrottle/subscriptions/server with your secret key.

Immediate cancellation

ts
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
  apiKey: process.env.THROTTLE_SECRET_KEY!,
});
// Cancel immediately. Status flips to 'cancelled' now.
await subscriptions.cancel('sub_xyz', { atPeriodEnd: false });
// → POST /api/v1/subscriptions/sub_xyz/cancel  body: { atPeriodEnd: false }

Use when the buyer wants to stop right now. Because this takes away access the buyer may have already paid for, it is the one cancellation that can also move money: refund accepts none (default), prorated, or last_cycle.

ts
// Cancel now AND send the money back for the current period.
// 'prorated'   → only the unused part of the period
// 'last_cycle' → everything charged for the current period
// 'none'       → default; access stops, money stays
await subscriptions.cancel('sub_xyz', { atPeriodEnd: false, refund: 'prorated' });
// → POST /api/v1/subscriptions/sub_xyz/cancel
//   body: { atPeriodEnd: false, refund: 'prorated' }

The refund is issued before the subscription is cancelled, so a payment failure leaves the subscription untouched rather than cancelling it with the money stranded. refund is rejected together with atPeriodEnd: true (400 validation_error): at period end the buyer keeps the period they paid for, so there is nothing to give back. A prorated cancel on a period nothing was paid for (a waived period, say) still cancels, with a refund of 0. A paused or past-due subscription is refunded the unused paid time from when it was paused (paused) or from now (past due). The refund is always capped at the cash paid for the period; if a period was paid but no unused time is left, the call is 422 invalid_state and nothing is cancelled. Asking for a refund on a subscription that is already cancelled or completed returns 409 invalid_state and refunds nothing.

Cancel at period end

ts
// Cancel at the end of the current period.
// Status stays 'active' until period ends, then flips to 'cancelled'.
await subscriptions.cancel('sub_xyz', { atPeriodEnd: true });
// → POST /api/v1/subscriptions/sub_xyz/cancel  body: { atPeriodEnd: true }

Common pattern: the buyer keeps access until the end of what they paid for. Throttle sets cancelAtPeriodEnd: true. The renewal cron sees it on the next tick after the period ends and finalizes the cancellation.

A scheduled cancellation is reversible until the period actually ends.
To restore a subscription that was scheduled to cancel, call POST /api/v1/subscriptions/:id/resume (it clears cancelAtPeriodEnd; PATCH does not accept that field). (Calling cancel again with atPeriodEnd: false does not undo it — that cancels immediately.) After actual cancellation the subscription is terminal — create a new one. Cancelling, either way, also clears any pending plan or seat change on the subscription.

Refund a billing cycle

Every subscription charge produces a subscription invoice, and each one can be refunded on its own — the signup cycle, a renewal, or a plan-change charge. Refunding a cycle is a money operation only: it does not stop billing unless you say so.

ts
// Refund one billing cycle. `intent` is required — there is no default,
// because "give the money back" and "give the money back and stop billing"
// are different decisions and Throttle will not guess which one you meant.
await subscriptions.refundInvoice('sub_xyz', 'subinv_abc', {
  intent: 'money_only',   // or 'refund_and_cancel'
  amount: 2500,           // optional; omit to refund the full remaining amount
  reason: 'Service outage',
});
// → POST /api/v1/subscriptions/sub_xyz/invoices/subinv_abc/refund

intent is required and has two values:

  • money_only — the money goes back and the subscription keeps running. The next renewal charges as normal.
  • refund_and_cancel — the money goes back and the subscription is cancelled immediately, in that order.

Omit amount to refund everything still refundable on the cycle. Partial refunds accumulate: the invoice moves to partially_refunded, then refunded once the whole amount is back. A cycle that is already fully refunded, unpaid, failed, or $0 (waived or free) is rejected with 422.

Refunding money does not stop billing on its own.
A refund with money_only leaves an active subscription active — the renewal cron will charge again on schedule. If the buyer is leaving, use refund_and_cancel (or cancel separately). The same is true of refunding the cycle's order from the Orders API: the money goes back and the cycle is marked refunded, but the subscription keeps billing.

The same holds for a checkout that created several subscriptions: no refund cancels the order or any of them, and an order refund that names no cycle comes out of the goods and shipping first, then across the cycles in proportion to what each still has refundable. Refunds are placed in the order they happened, each against what was left at that moment.

Refunding through either surface reaches the same numbers. The amount refunded on a cycle is derived from the payment's own transaction ledger, so a refund taken on the order that collected the cycle updates the cycle and its invoice too — and the refundable amount the next call is offered already has it subtracted.

When the order billed more than the cycle — a one-time setup fee charged alongside the first period — a refund pays down the non-recurring part first, and only what is left over reaches the cycle. Refunding a $40 setup fee on a $65 order that also carried a $25 first period leaves the cycle fully refundable; refunding the remaining $25 then closes it. Refunds carry no line-item attribution, so this is the direction that keeps a one-time refund from locking you out of refunding the subscription itself.

Both intents emit subscription.invoice_refunded, whose payload carries the intent so your systems can tell a goodwill refund from a cancellation. refund_and_cancel additionally emits subscription.cancelled.

Requires the payment_refunds:write scope.

Pause and resume

ts
// Pause an active subscription. Renewal cron skips it.
await subscriptions.pause('sub_xyz');
// Resume back to active. The next periodEnd will trigger a renewal as normal.
await subscriptions.resume('sub_xyz');

Pausing an active subscription transitions it to paused. The renewal cron skips paused rows entirely — no charge attempt, no dunning. Resuming returns it to active and pushes currentPeriodEnd forward by however long the subscription was paused, so the buyer gets back the days billing was stopped instead of being charged for them.

Guards:

  • pause requires status active. Any other status (including trialing, which has nothing billed to pause) is 400 invalid_subscription_state.
  • resume on a paused subscription always unpauses it, whether or not it also has cancelAtPeriodEnd: true — the scheduled cancellation is left exactly as it was. On a subscription that is not paused, resume clears a scheduled cancellation (cancelAtPeriodEnd: true) in any non-cancelled status (active, trialing, past_due) and leaves the status alone; on an active subscription with nothing scheduled it is a no-op that returns the subscription. For cancelled and completed it is 400 invalid_subscription_state.
  • A paused subscription that also has cancelAtPeriodEnd: true is still cancelled at its stored currentPeriodEnd — pausing does not push that date out (only resume does).
  • While paused, an immediate (effective: "now") plan or seat change is 422 invalid_state — it would charge the card. Resume first, or schedule the change with effective: "period_end".

Pause, resume, and cancel stay allowed on an installment plan when called with your merchant secret key — while paused nothing is charged and the payment counter waits. Only the buyer session in the buyer portal is blocked from these three ( 403 installment_plan_merchant_only); see Buyer Portal.

Change the card

ts
// Move ONE subscription to another of its customer's saved cards.
// List the customer's cards with listCustomerPaymentMethods(customerId).
const sub = await subscriptions.setPaymentMethod('sub_xyz', 'pm_456');
// → PATCH /api/v1/subscriptions/sub_xyz/payment-method  body: { paymentMethodId: 'pm_456' }
// sub.paymentMethodId === 'pm_456'. Nothing is charged now.

PATCH /api/v1/subscriptions/:id/payment-method with { paymentMethodId } sets the saved card this one subscription renews on (its paymentMethodId). Only this subscription changes. Setting the customer's default card is different: it moves every subscription that was on the old default (see Which card a renewal charges ).

  • Nothing is charged now. The next renewal, or the next dunning retry of a past_due subscription, charges the new card. To charge a past-due subscription straight away, call retry-charge after changing the card.
  • Allowed in every state except cancelled and completed: active, trialing, paused, past_due, and installment plans (the card is not one of an installment plan's locked terms). A past_due subscription keeps its dunning count and any renewalIssue.
  • Fires subscription.updated once, with the new data.subscription.paymentMethodId. Sending the card the subscription already has returns 200 and fires nothing.
  • Scope subscriptions:write. Dashboard users need an owner, workspace admin, app admin or app developer role.

Errors:

  • 409 payment_method_unusable — the card is not one of this subscription customer's saved cards in this application and environment, or it is paused, expired, or not vaulted for renewals.
  • 409 invalid_state — the subscription is cancelled or completed.
  • 404 not_found — no such subscription in this application and environment.
  • 400 validation_error — paymentMethodId is missing or not a UUID, or the body has another field.

Buyers can do the same for their own subscriptions through the storefront plane ( PATCH /v1/storefront/me/subscriptions/:id/payment-method ) or the hosted billing link ( PATCH /api/v1/me/subscriptions/:id/payment-method ). In the dashboard, use Change card under Payment on the subscription page.

Change plan

Use POST /api/v1/subscriptions/:id/change-plan for all mid-cycle plan changes. The behavior depends on the effective field.

Locked on installment plans
An installment plan's amount, cadence, quantity, and plan are fixed at creation (totalPayments is set). Both change-plan and change-quantity below are rejected with 409 installment_plan_locked on one. Cancel it and create a new subscription to change the terms.

Immediate change

Use effective: "now" when the buyer is moving to a different plan and you want it in force right away. Throttle prorates the change: it credits the unused portion of the current period, charges the stored card for the new plan amount minus that credit (never below zero), and resets the billing period from today. The response includes a proration object ( creditCents, chargedCents, fullAmount) and the same breakdown rides on the subscription.plan_changed event. The charge goes to the subscription's own card (paymentMethodId) first; if that card is declined, the customer's default card is tried once. If the card is declined the route returns 402 payment_failed and nothing changes. When the payment provider gives no final answer (still in flight or a transport error), no other card is charged and the route returns 409 payment_unverified with details.transactionId (which can be null). Retry the same change to reconcile the original charge; a different immediate change is blocked with 409 renewal_needs_attention while it is pending. If the change is still unresolved at 12 hours, it becomes payment_review: further charges are blocked and the merchant is alerted. A capture found by the background watcher waits for the same change to be retried; support must reconcile it if review begins.

After a definitive decline, the same change on the same card within 24 hours replays that decline. A different card or a new change gets a new key; do not switch cards to work around a pending charge. A request rejected because of processor credentials or configuration returns 402 payment_failed with message: "processor_config_error" and actionable details. Check the payment connector; this is not a card decline.

How the credit is worked out

  • The credit base is the value of the current period: cash paid for it (net of any refund) plus the credit already applied on the current period's invoice(s), capped at the plan's base amount. The unused share of that base (linear, by time remaining) is the credit. Counting the applied credit is what stops a second immediate change in the same period from crediting only the cash top-up and charging the buyer twice for the same time.
  • A change fully covered by credit issues no charge (processors reject a $0 charge): the change is applied, and a $0 paid subscription invoice is recorded for the new period carrying the credit in creditAppliedCents. No order or payment is created, because no money moved.
  • Refunds stay cash-only. A prorated cancel is measured against the cash actually paid for the period, never the credit base, so it can never refund more than was collected. A $0 credit-covered invoice has no cash to refund.
  • A waived, free, or fully-refunded period has no value to credit, so it credits 0. A subscription with no invoice history at all (created before invoice recording, June 2026) credits against the plan price instead.
  • Credit only accrues for an active subscription. A past_due subscription has no paid time to credit, so an immediate change charges the full new amount; a successful charge settles it — status returns to active, the failure count clears, and the new period starts today.
  • A trialing subscription has paid nothing, so an immediate change charges nothing: the new terms are swapped in place, the trial and billing dates stay, and the trial conversion bills the new terms.

The credit appears on the billing history: GET /api/v1/subscriptions/:id/invoices returns one row per charge, each with amount (cash charged), amountRefunded, and creditAppliedCents (the credit applied to produce that row; 0 for an ordinary renewal). A period settled without a charge (a waive, or a renewal that cost nothing) has a $0 paid row with waived: true; every other row has waived: false.

An immediate change to the exact terms the subscription already has (same plan reference, cadence, and amount) is refused with 400 invalid_subscription_state ("Nothing to change") before anything is charged, so a retried request cannot bill twice.

ts
// Immediate upgrade: prorated charge now, resets the billing period.
// → POST /api/v1/subscriptions/sub_xyz/change-plan
const result = await fetch('https://api.usethrottle.dev/api/v1/subscriptions/sub_xyz/change-plan', {
  method: 'POST',
  headers: { 'x-api-key': process.env.THROTTLE_SECRET_KEY!, 'content-type': 'application/json' },
  body: JSON.stringify({
    planReference: 'pro_yearly',
    planName: 'Pro Yearly',
    interval: 'yearly',
    amount: 29900,
    effective: 'now',           // <-- prorated charge now
  }),
});
const { data } = await result.json();
// data.proration = { creditCents, chargedCents, fullAmount }
// e.g. mid-period on a subscription that paid 9900 for the period, 3 days in
// of 30: creditCents 8910 (unused paid time) → chargedCents 20990 (29900 − 8910).
// On 402: stored card declined. Let the buyer update their payment method.

Scheduled change

Use effective: "period_end" when the buyer is moving to a cheaper plan and should finish the period they paid for. No charge is made now. A scheduled change to the same plan reference is allowed as long as the terms differ (a new cadence or amount); only a change that alters nothing — same plan, cadence, and amount — is 400 invalid_subscription_state. The four pending* fields ( pendingPlanReference, pendingPlanName, pendingInterval, pendingAmount) are written, and the renewal cron applies the change on the next period end.

ts
// Scheduled downgrade: no charge now; applies on the next renewal.
// → POST /api/v1/subscriptions/sub_xyz/change-plan
const result = await fetch('https://api.usethrottle.dev/api/v1/subscriptions/sub_xyz/change-plan', {
  method: 'POST',
  headers: { 'x-api-key': process.env.THROTTLE_SECRET_KEY!, 'content-type': 'application/json' },
  body: JSON.stringify({
    planReference: 'starter_monthly',
    planName: 'Starter Monthly',
    interval: 'monthly',
    amount: 999,
    effective: 'period_end',    // <-- deferred
  }),
});
// subscription.pendingPlanReference, pendingInterval, pendingAmount are now set.
// subscription.plan_change_scheduled webhook fires.

Cancelling a pending change

If the buyer changes their mind about a scheduled downgrade, use DELETE /api/v1/subscriptions/:id/pending-change to clear the pending fields and keep the current plan. Calling this when no change is pending is a safe no-op.

ts
// Cancel a previously scheduled downgrade. The subscription stays on its current plan.
// → DELETE /api/v1/subscriptions/sub_xyz/pending-change
const result = await fetch('https://api.usethrottle.dev/api/v1/subscriptions/sub_xyz/pending-change', {
  method: 'DELETE',
  headers: { 'x-api-key': process.env.THROTTLE_SECRET_KEY! },
});
// All pending_* fields are now null. subscription.updated fires.
A scheduled change and cancel-at-period-end never coexist
Scheduling a change (effective: "period_end") on a subscription that already has cancelAtPeriodEnd: true is refused with 409 invalid_state. Undo the cancellation first ( POST /api/v1/subscriptions/:id/resume), then schedule the change. In the other direction, cancelling (at period end or immediately) clears any pending plan or seat change in the same call.

Seats and quantity

A subscription carries a quantity (default 1). The amount is the per-seat price, so the amount billed each period is amount × quantity (the renewal invoice line item carries the seat count). Set quantity when you create the subscription, and change it mid-cycle with POST /api/v1/subscriptions/:id/change-quantity.

Like a plan change, the seat change is either effective: "now" or effective: "period_end":

  • Immediate — prorated exactly like an immediate plan change: it credits the unused time on the old total ( amount × oldQuantity) and charges the net of the new total (amount × newQuantity), then resets the billing period. The credit base follows the same rule as a plan change (cash paid plus credit already applied this period, capped at the old total). Removing seats where the credit exceeds the new total issues no charge and records a $0 paid invoice carrying the credit. The response carries the same proration object.
  • Period end — writes pendingQuantity; the renewal cron applies it at the next period boundary and bills the new seat count. No charge now.
ts
// Add seats mid-cycle (prorated). amount is the PER-SEAT price.
// → POST /api/v1/subscriptions/sub_xyz/change-quantity
const result = await fetch('https://api.usethrottle.dev/api/v1/subscriptions/sub_xyz/change-quantity', {
  method: 'POST',
  headers: { 'x-api-key': process.env.THROTTLE_SECRET_KEY!, 'content-type': 'application/json' },
  body: JSON.stringify({
    quantity: 5,             // new seat count
    effective: 'now',        // prorated now, or 'period_end' to defer
  }),
});
const { data } = await result.json();
// data.quantity = 5; data.proration = { creditCents, chargedCents, fullAmount }.
// fullAmount = amount × 5; charged = fullAmount − credit for unused time on the old seat count.
// Sending the quantity the subscription already has → 400 quantity_unchanged.

Sending the quantity the subscription already has is 400 quantity_unchanged ("Subscription already has N seat(s)"), checked before anything is charged. On a trialing subscription an immediate seat change charges nothing and swaps the quantity in place. Both cancelled and completed subscriptions answer 422 invalid_state to any change.

From the React package

tsx
// React: same operations via @usethrottle/subscriptions hooks.
import {
  useCancelSubscription,
  usePauseSubscription,
  useResumeSubscription,
  useChangePlan,
} from '@usethrottle/subscriptions';
function SubActions({ sub }) {
  const cancel = useCancelSubscription();
  const pause = usePauseSubscription();
  const resume = useResumeSubscription();
  const change = useChangePlan();
  return (
    <>
      {sub.status === 'active' && (
        <button onClick={() => pause.mutate({ id: sub.id })}>Pause</button>
      )}
      {sub.status === 'paused' && (
        <button onClick={() => resume.mutate({ id: sub.id })}>Resume</button>
      )}
      <button onClick={() => cancel.mutate({ id: sub.id, atPeriodEnd: true })}>
        Cancel at period end
      </button>
      <button onClick={() => change.mutate({ id: sub.id, planReference: 'pro_yearly', interval: 'yearly', amount: 29900, effective: 'now' })}>
        Switch to yearly
      </button>
    </>
  );
}

The hooks invalidate the right cache entries on success — your useSubscription(id) and useSubscriptions() data update without a manual refetch.

Audit log

Every state change is recorded in the audit log with the actor (API key, user, or system) and the change set. View it in the dashboard under Customers → Subscriptions → Activity.

Never change a plan through checkout

Checkout always creates a new recurring subscription. Sending an existing subscriber back through it leaves them with two live subscriptions and two charges — and nothing errors, because creating a subscription for a customer who already has one is a legitimate operation.

Use change-plan for anyone with a live subscription, and fall back to checkout only when there is none to change (a lapsed customer resubscribing is genuinely a new subscription).

Handle the 402

An immediate change charges the stored card, so it can decline. A 402 payment_failed means there is no usable card on file — surface "add a payment method" rather than a generic failure, because it is the most common real-world outcome of an upgrade attempt and it is entirely recoverable by the buyer.

A server without immediate plan changes configured returns 501 not_implemented instead, which is a deployment problem rather than a buyer one.

Next