Subscriptions

React Package · @usethrottle/subscriptions

Production React hooks, server proxy helpers, and primitive components for managing Throttle subscriptions end-to-end from your buyer portal.

Production package
Browser code should use the provider and hooks through your backend proxy. Backend code can import createSubscriptionsClient from the server entrypoint and use it with your secret key. The package never requires putting an sk_* key in browser code.

Install

bash
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
// 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.

tsx
'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.

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

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

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

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

tsx
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()

tsx
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()

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

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

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

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

tsx
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) — null for 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.

Primitive components

Three presentational components. They take a subscription (or a single field) as a prop and render appropriately. No secret-key API calls.

tsx
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. Accepts className.
  • <TrialCountdown endDate={date} /> — "Trial ends in N days". Pass the trial end date.
  • <DunningBanner subscription={sub} onUpdateCard={fn} /> — shown when status is past_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 / style for your design system.