Skip to content
revenue-sdk
Esc
navigateopen⌘Jpreview
On this page

Subscription management UI

Build an account page — show status and a cancellation banner, cancel, uncancel, change plan, and open the portal.

An account page needs to answer three questions — am I subscribed?, what am I paying?, and what can I change? — and offer five actions: cancel, uncancel, pause, change plan, and manage billing details.

Load the state

import { createClient } from 'revenue-sdk';
import { polar } from 'revenue-sdk/polar';

const client = createClient({
  provider: polar({ accessToken: process.env.POLAR_ACCESS_TOKEN! }),
});

export async function loadBilling(userId: string) {
  const user = await getUser(userId);
  if (!user.providerSubscriptionId) {
    return { subscription: undefined, plans: await loadPlans() };
  }

  const subscription = await client.subscriptions.get({ id: user.providerSubscriptionId });
  return { subscription, plans: await loadPlans() };
}

Show status honestly

The unified status model makes this a lookup rather than a per-provider branch:

import type { Subscription } from 'revenue-sdk';

const LABELS: Record<Subscription['status'], string> = {
  incomplete: 'Awaiting first payment',
  trialing: 'Trial',
  active: 'Active',
  past_due: 'Payment failed — retrying',
  unpaid: 'Payment failed',
  paused: 'Paused',
  canceled: 'Ended',
};

const ENTITLED: ReadonlySet<Subscription['status']> = new Set(['active', 'trialing', 'past_due']);

export function statusView(subscription: Subscription) {
  return {
    label: LABELS[subscription.status],
    entitled: ENTITLED.has(subscription.status),
    renewsAt: subscription.cancelAtPeriodEnd ? undefined : subscription.currentPeriodEnd,
    endsAt: subscription.cancelAtPeriodEnd ? subscription.endsAt : undefined,
  };
}

The cancellation banner

A scheduled cancellation keeps status unchanged, so the banner keys off cancelAtPeriodEnd, not the status:

export function CancellationBanner({ subscription }: { subscription: Subscription }) {
  if (!subscription.cancelAtPeriodEnd) return null;

  return (
    <aside role="status">
      <p>
        Your subscription ends on{' '}
        <strong>{subscription.endsAt?.toLocaleDateString() ?? 'the end of this period'}</strong>. You
        keep full access until then.
      </p>
      <form method="post" action="/api/billing/uncancel">
        <button type="submit">Resume subscription</button>
      </form>
    </aside>
  );
}

status === 'canceled' is a different, terminal state — show a “start a new subscription” call to action there, not a resume button. See Subscription lifecycle.

Cancel

// POST /api/billing/cancel
import { RevenueError } from 'revenue-sdk';

export async function POST(request: Request): Promise<Response> {
  const user = await requireUser(request);
  const form = await request.formData();
  const reason = form.get('reason');

  try {
    const subscription = await client.subscriptions.cancel({
      id: user.providerSubscriptionId!,
      // Only send the reason where the provider forwards it.
      reason: client.capabilities.cancellationReason ? toReason(reason) : undefined,
    });
    await upsertSubscription(subscription);
    return Response.redirect('/account/billing', 303);
  } catch (error) {
    if (error instanceof RevenueError) {
      return new Response(error.message, { status: 400 });
    }
    throw error;
  }
}

toReason maps your survey answer onto a unified CancellationReason'too_expensive', say; TypeScript lists the rest at the call site. The cancellationReason capability decides whether the provider forwards it at all, which is why the branch above drops it rather than guessing.

To end access immediately instead, use subscriptions.revoke, gated by the revoke capability:

if (client.capabilities.revoke) {
  await client.subscriptions.revoke({ id: subscriptionId });
}

Uncancel

// POST /api/billing/uncancel
const subscription = await client.subscriptions.uncancel({ id: user.providerSubscriptionId! });
await upsertSubscription(subscription);

All five providers support it. It reverts a scheduled cancellation only — once status is canceled the subscription is gone and the customer needs a new checkout.

On Lemon Squeezy, PayPal-paid subscriptions can’t be mutated at all — every PATCH-backed call throws unsupported; see the Lemon Squeezy page.

Pause and resume

Pausing keeps the subscription alive but stops the billing. Render the control only where the pause capability is on, and pick a behavior the provider actually supports:

// POST /api/billing/pause
if (!client.capabilities.pause) {
  return new Response('Pausing is not available on this plan', { status: 409 });
}

const subscription = await client.subscriptions.pause({
  id: user.providerSubscriptionId!,
  // Omit behavior to accept the provider's default, or pick one it supports.
  behavior: client.capabilities.pauseBehaviors.includes('period_end') ? 'period_end' : 'immediately',
  resumesAt: new Date('2026-10-01'), // omit to pause indefinitely
});
await upsertSubscription(subscription);
// POST /api/billing/resume
const subscription = await client.subscriptions.resume({ id: user.providerSubscriptionId! });
await upsertSubscription(subscription);

A behavior outside capabilities.pauseBehaviors throws unsupported; resume always takes effect immediately. Which providers pause, and how, is in the capability matrix.

An immediate pause reports status: 'paused', which the entitlement check above already treats as no access. A period-end pause leaves status untouched and sets pauseAtPeriodEnd, so the banner for it keys off that flag exactly like the cancellation banner:

if (subscription.pauseAtPeriodEnd) {
  banner(`Your subscription pauses on ${subscription.currentPeriodEnd?.toLocaleDateString()}.`);
}

resumesAt is set whenever the pause has an automatic end date — show it so customers know when billing restarts.

Change plan

Offer only the plans the customer isn’t already on, and pass checkoutRef — never Product.id:

export function PlanSwitcher({ subscription, plans }: Props) {
  const proration = client.capabilities.prorationBehaviors.includes('prorate')
    ? 'prorate'
    : 'invoice_now';

  return (
    <form method="post" action="/api/billing/change-plan">
      <input type="hidden" name="prorationBehavior" value={proration} />
      {plans
        .filter((plan) => plan.checkoutRef !== subscription.priceId)
        .map((plan) => (
          <button key={plan.checkoutRef} name="checkoutRef" value={plan.checkoutRef} type="submit">
            Switch to {plan.name} — {plan.price}
          </button>
        ))}
    </form>
  );
}
// POST /api/billing/change-plan
const subscription = await client.subscriptions.changePlan({
  id: user.providerSubscriptionId!,
  product: checkoutRef,
  prorationBehavior,
});
await upsertSubscription(subscription);

Pick the proration behavior from capabilities.prorationBehaviors, as the snippet above does, rather than hard-coding one of 'prorate', 'invoice_now' or 'none' — not every provider offers all three. The matrix is in the capability matrix.

There is no capability for seats: pass quantity only if you know your provider accepts it on a plan change, since Polar and Lemon Squeezy throw unsupported for anything but 1.

Payment methods, invoices, and tax details belong in the provider’s portal — don’t rebuild them. Mint the session on click, because the URLs expire:

// GET /api/billing/portal
export async function GET(request: Request): Promise<Response> {
  const user = await requireUser(request);

  const session = await client.customerPortal.createSession({
    customerId: user.providerCustomerId!,
    returnUrl: client.capabilities.portalReturnUrl
      ? 'https://example.com/account/billing'
      : undefined,
  });

  return Response.redirect(session.url, 302);
}
<a href="/api/billing/portal">Manage billing details</a>

Refresh state after every mutation

Every mutating call returns the updated Subscription. Write it straight back so the page reflects the change without waiting for the webhook — the webhook will arrive shortly after and converge on the same state, which is exactly why the handler upserts.

const subscription = await client.subscriptions.cancel({ id });
await upsertSubscription(subscription); // same function the webhook uses

Last updated on August 8, 2026

Was this page helpful?