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
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. You may want to combine this with a prorated refund (handled outside Throttle's subscription engine).
Cancel at period end
// 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.
PATCH /api/v1/subscriptions/:id with { cancelAtPeriodEnd: false }. After actual
cancellation the subscription is terminal — create a new one.
Pause and resume
// 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; the period continues from where it was
paused.
Guards:
-
pause()requires statusactive. Throws otherwise. -
resume()requires statuspaused. Throws otherwise.
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.
// 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.
// 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.
// 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. 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":
- 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. Removing seats where the credit exceeds the new total issues no charge. The response carries the sameprorationobject. - Period end — writes
pendingQuantity; the renewal cron applies it at the next period boundary and bills the new seat count. No charge now.
// 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
// 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
- Buyer Portal — let buyers do this themselves.
- Subscription Webhooks — events fired for each transition.