React Package · @usethrottle/subscriptions
Production React hooks, server proxy helpers, and primitive components for managing Throttle subscriptions end-to-end from your buyer portal.
createSubscriptionsClient from the server
entrypoint and use it with your secret key. The package never requires putting an sk_* key in browser code.
Install
npm install @usethrottle/subscriptions @usethrottle/checkout-react Backend proxy
The package ships a Next.js-compatible proxy helper at @usethrottle/subscriptions/server. It allowlists the
subscription, customer-by-external, trial eligibility, and checkout-session paths, pins
subscription reads, customer lookups, direct subscription creates, and checkout-session
creates to the authenticated buyer's externalCustomerId, and verifies ownership before
forwarding subscription mutations.
// app/api/throttle/[...path]/route.ts
import { createSubscriptionProxyHandler } from '@usethrottle/subscriptions/server';
import { auth } from '@/lib/auth';
const handler = createSubscriptionProxyHandler({
apiKey: process.env.THROTTLE_SECRET_KEY!,
async getExternalCustomerId() {
const user = await auth();
return user?.id ?? null;
},
});
export { handler as GET, handler as POST, handler as PATCH }; Provider
Mount a SubscriptionProvider high in your tree. The fetcher prop is the contract with your backend proxy
— every hook routes through it.
'use client';
import { SubscriptionProvider } from '@usethrottle/subscriptions';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<SubscriptionProvider
fetcher={async (path, init) => fetch(`/api/throttle${path}`, init)}
>
{children}
</SubscriptionProvider>
);
} Server-side client
Backend routes can use the server entrypoint as a typed subscription client without bundling React provider code. Use this for admin tooling, webhook handlers, or custom business logic that should run server-side.
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
await subscriptions.cancel('sub_123', { atPeriodEnd: true }); Subscription confirmation pages and account portals can also fetch vaulted payment-method summaries without reaching around the package.
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!,
});
const paymentMethods = await subscriptions.listCustomerPaymentMethods('cus_123'); Hooks · queries
useSubscriptions(filters)
Lists subscriptions for a customer.
import { useSubscriptions } from '@usethrottle/subscriptions';
function MySubs() {
const { data, isLoading, error } = useSubscriptions({
externalCustomerId: 'user_42', // or { customerId: 'cus_xyz' }
status: 'active', // optional
});
if (isLoading) return <Spinner />;
if (error) return <ErrorView error={error} />;
return data.data.map((sub) => <SubRow key={sub.id} subscription={sub} />);
}
Filters: customerId or externalCustomerId (one is required), plus optional status (now including unpaid and completed), interval (custom matches
every non-named cadence), installment (
true for installment plans only, false for ongoing subscriptions only), cursor, limit.
useSubscription(id)
Fetches a single subscription.
import { useSubscription } from '@usethrottle/subscriptions';
function SubDetail({ id }: { id: string }) {
const { data: sub, isLoading } = useSubscription(id);
if (isLoading || !sub) return <Spinner />;
return <SubCard subscription={sub} />;
} useCustomerPaymentMethods(customerId)
Fetches saved payment-method summaries. When routed through the backend proxy, payment method reads are pinned to the authenticated buyer's customer row.
import { useCustomerPaymentMethods } from '@usethrottle/subscriptions';
function SavedCards({ customerId }: { customerId: string }) {
const { data: methods, isLoading } = useCustomerPaymentMethods(customerId);
if (isLoading) return <Spinner />;
return methods?.map((method) => (
<span key={method.id}>
{method.cardBrand} ending in {method.cardLastFour}
</span>
));
} Hooks · mutations
All mutation hooks return a { mutate, mutateAsync, isPending, error, data }
shape. On success they invalidate the relevant useSubscription / useSubscriptions caches.
useCancelSubscription()
import { useCancelSubscription } from '@usethrottle/subscriptions';
function CancelButton({ subId }: { subId: string }) {
const cancel = useCancelSubscription();
return (
<button
disabled={cancel.isPending}
onClick={() => cancel.mutate({ id: subId, atPeriodEnd: true })}
>
{cancel.isPending ? 'Cancelling…' : 'Cancel at period end'}
</button>
);
} usePauseSubscription() / useResumeSubscription()
import { usePauseSubscription, useResumeSubscription } from '@usethrottle/subscriptions';
function PauseToggle({ sub }) {
const pause = usePauseSubscription();
const resume = useResumeSubscription();
return sub.status === 'paused'
? <button onClick={() => resume.mutate({ id: sub.id })}>Resume</button>
: <button onClick={() => pause.mutate({ id: sub.id })}>Pause</button>;
} useChangePlan()
A real plan change — prorated and charged immediately when effective: 'now', or scheduled for period end with no
charge today. For a term-only edit with no proration and no charge (rename a plan, correct
the amount on the row), call the client's update(id, input)
instead.
import { useChangePlan } from '@usethrottle/subscriptions';
function PlanSwitcher({ subId }: { subId: string }) {
const change = useChangePlan();
return (
<button onClick={() => change.mutate({
id: subId,
planReference: 'pro_yearly',
interval: 'yearly',
amount: 29900,
effective: 'now', // or 'period_end' to schedule it, charging nothing today
})}>
Switch to yearly
</button>
);
} useCreateCheckoutSession()
Bridge from the management package to @usethrottle/checkout-react's <PaymentEmbed />. Wraps the /api/v1/checkout/sessions POST with recurring
metadata. recurring is optional: omit it when the cart's
lines carry recurring.
import { useCreateCheckoutSession } from '@usethrottle/subscriptions';
import { PaymentEmbed } from '@usethrottle/checkout-react';
function SubscribeFlow({ user, plan }) {
const create = useCreateCheckoutSession();
if (!create.data) {
return (
<button onClick={() => create.mutate({
applicationId: '00000000-0000-0000-0000-000000000000',
externalCartId: `sub-${user.id}-${plan}`,
customer: { externalCustomerId: user.id, email: user.email },
returnUrl: 'https://shop.example.com/account',
cancelUrl: 'https://shop.example.com/pricing',
recurring: { plan, interval: 'monthly', amount: 2999 },
})}>
Subscribe
</button>
);
}
return (
<PaymentEmbed
sessionId={create.data.sessionId}
parentOrigin="https://shop.example.com"
// one per subscription line; subscriptionId is set only when there is exactly one
onSucceeded={({ subscriptionIds }) => router.push(`/account/${subscriptionIds?.[0]}`)}
/>
);
} useTrialEligibility()
Calls POST /api/v1/subscriptions/eligibility-check
through your proxy. Use it when your UI needs to decide whether to offer a trial after a
payment method has already been vaulted.
import { useTrialEligibility } from '@usethrottle/subscriptions';
function TrialGate({ paymentMethodId }: { paymentMethodId: string }) {
const check = useTrialEligibility();
return (
<button onClick={() => check.mutate({ paymentMethodId })}>
Check trial eligibility
</button>
);
} Custom cadence and installment plans
Subscription carries the fields a custom cadence and an
installment plan need: intervalUnit (
hour | day | week | month) and intervalCount alongside interval (which now also accepts "custom"), plus totalPayments, paymentsMade,
and completedAt for installment plans (on an ongoing
subscription totalPayments and completedAt are null and paymentsMade is 0). SubscriptionStatus adds unpaid
and completed.
Three helpers turn those fields into copy instead of every app re-deriving its own label map — the exact bug custom cadences were built to retire:
import { cadenceNoun, cadenceLabel, installmentProgress } from '@usethrottle/subscriptions';
function SubRow({ sub }) {
// "$49.00 / 10 days" for a custom cadence; named cadences read "$49.00 / month".
const priceLine = `${formatMoney(sub.amount, sub.currency)} / ${cadenceNoun(sub)}`;
// "Every 10 days" / "Monthly" — a standalone label, e.g. an Interval column.
const interval = cadenceLabel(sub);
const progress = installmentProgress(sub); // null for an ongoing subscription
return (
<article>
<p>{priceLine}</p>
<p>{interval}</p>
{progress && (
<p>
Payment {progress.paymentsMade} of {progress.totalPayments} ·{' '}
{formatMoney(progress.remainingCents, sub.currency)} remaining
</p>
)}
</article>
);
} -
cadenceNoun(sub)— the period noun after a slash:"10 days","2 weeks","month". -
cadenceLabel(sub)— a standalone label:"Monthly","Every 10 days". -
installmentProgress(sub)—nullfor an ongoing subscription, or{ paymentsMade, totalPayments, remainingPayments, remainingCents }for an installment plan.
create() and useCreateSubscription() accept intervalUnit + intervalCount
alongside interval, and totalPayments (2–60) to create an installment plan.
- Full guide: Custom Cadence → — bounds, the label table, and renewal dates.
- Full guide: Installment Plans → — counting, locks, completion, and a progress component.
Primitive components
Three presentational components. They take a subscription (or a single field) as a prop and render appropriately. No secret-key API calls.
import {
SubscriptionStatusBadge,
TrialCountdown,
DunningBanner,
} from '@usethrottle/subscriptions';
function SubCard({ subscription: sub }) {
return (
<article>
<h3>{sub.planName}</h3>
<SubscriptionStatusBadge status={sub.status} />
{sub.status === 'trialing' && sub.trialEnd && (
<TrialCountdown endDate={sub.trialEnd} />
)}
{sub.status === 'past_due' && (
<DunningBanner subscription={sub} onUpdateCard={() => openUpdateCardFlow(sub)} />
)}
<p>Next charge: {sub.currentPeriodEnd}</p>
</article>
);
} -
<SubscriptionStatusBadge status={status} />— chip with state-specific colour. AcceptsclassName. -
<TrialCountdown endDate={date} />— "Trial ends in N days". Pass the trial end date. -
<DunningBanner subscription={sub} onUpdateCard={fn} />— shown when status ispast_due. Includes an action that calls your update-card flow.
Boundaries
-
Payment collection stays in
@usethrottle/checkout-react. This package creates recurring sessions and manages the subscription lifecycle around the embed. - Direct browser auth via customer-scoped tokens is not exposed yet. Use the proxy helper so your backend owns authorization and secret-key storage.
-
Composite portal layouts are application code. Components here are primitives and accept
className/stylefor your design system.