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

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

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"
      onSucceeded={({ subscriptionId }) => router.push(`/account/${subscriptionId}`)}
    />
  );
}

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

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.