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.
The billing portal link
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