Subscriptions

Managing Subscriptions

Pause, resume, cancel, and change plans. 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: at period end the buyer keeps the period they paid for, so there is nothing to give back.

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.

Either form is reversible — call cancel again with atPeriodEnd: false to undo (until the period actually ends).
To restore a subscription that was scheduled to cancel, call PATCH /api/v1/subscriptions/:id with { cancelAtPeriodEnd: false }. After actual cancellation the subscription is terminal — create a new one.

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, or failed 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.

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. Throws otherwise.
  • resume() requires status paused — unless the subscription has cancelAtPeriodEnd: true, in which case it clears the scheduled cancellation in any non-cancelled status (active, trialing, past_due) and leaves the status alone. Throws for cancelled.

Change plan

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

Immediate upgrade

Use effective: "now" when the buyer is moving to a more expensive plan and you want to grant access right away. Throttle prorates the change: it credits the unused portion of the current period on the old plan and charges the stored card for the new plan amount minus that credit, then 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. If the credit fully covers the new amount, no charge is issued. If the card is declined the route returns 402 payment_failed and nothing changes. Credit only accrues for active subscriptions — a trialing or past-due subscription has no prepaid time to credit.

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: fullAmount 29900, creditCents 4100 unused → chargedCents 25800.
// On 402: stored card declined. Let the buyer update their payment method.

Scheduled downgrade

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. 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.
Precedence: cancelAtPeriodEnd wins over a pending change
If the subscription already has cancelAtPeriodEnd: true, the cancellation takes precedence and the pending plan change will never apply. Clear the cancel flag first (PATCH with { cancelAtPeriodEnd: false }), then schedule the plan change.

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

  • Immediateprorated 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. Removing seats where the credit exceeds the new total issues no charge. 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.

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 })}>
        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