SaaS kit: Stripe Customer Portal for self-serve plan changes and cancels

Self-serve billing is not a settings screen you invent under deadline. It is the moment a paying customer changes plan, updates a card, or cancels without filing a ticket — and your product either keeps entitlement honest or drifts into manual spreadsheets. Stripe's Customer Portal exists so you do not rebuild that dashboard. On an owned SaaS kit, the job is wiring a portal session from a privileged route, then letting webhooks keep workspace access aligned with the subscription Stripe already stores.
This post is the BOFU how-to for that spine on OTF's SaaS Dashboard kit. It is not the live demo tour at Click-by-click tour of saas.otf-kit.dev, not the greenfield path at How to build a SaaS app with AI, and not the generic billing-ownership piece at Auth and billing you don't hand-roll. If your buyer question is "how do plan changes, payment-method updates, and cancels stay self-serve after Checkout already works?", stay here.
What the SaaS kit already owns vs what the portal adds
OTF lists the SaaS Dashboard kit on https://otf-kit.dev/templates/saas-dashboard and in the templates catalog. Standalone price is $99; it is also in the Everything Bundle on https://otf-kit.dev/pricing. Live proof before you buy sits at https://saas.otf-kit.dev. Public product claims that matter for this angle:
- Operator screens for dashboard, analytics, issues, projects, teams, inbox, and settings
- Auth session tables plus workspace, teams, projects, and issues in schema you migrate
- Stripe Checkout plus webhook handling so purchase confirmation stays on a privileged server path after you wire live keys
- Agent handoff files (
CLAUDE.md,.cursorrules, and prompts underai/prompts/) so Cursor or Claude Code extend the repo instead of inventing a second billing system
Checkout gets someone onto a plan. The Customer Portal is the ongoing surface: upgrades, downgrades, payment methods, invoices, and cancellation rules you configure once in Stripe. Stripe documents that flow as configure the portal, create a portal session, redirect to the session URL, then listen for subscription and payment-method webhooks (Integrate the customer portal).
Do not treat "Settings" chrome in the kit as a finished billing console. Settings can host the Manage billing button. Stripe hosts the portal.

Configure the portal before you write a route
Stripe's integration guide is explicit: register products and prices, then use the Dashboard (or the Billing Portal Configuration API) to decide what customers may do — invoice history, payment-method updates, subscription cancel, and whether plan changes are allowed. If customers can upgrade or downgrade, you must also set a product catalog the portal can present (Integrate the customer portal).
Practical order on an owned SaaS repo:
- Keep Checkout and the purchase webhook path working in test mode first. Idempotent webhook handling still matters when Stripe retries — see Stripe sent the same purchase webhook 4 times.
- In the Stripe Dashboard Customer portal settings, enable only the actions you are ready to honor in your app (cancel at period end vs immediate, plan changes, payment methods).
- Preview the portal against a test customer before any production toggle. Stripe documents a read-only Preview and a per-customer "Open customer portal" action in sandboxes.
- Mirror live-mode configuration separately. Stripe keeps distinct portal configurations for live mode and each sandbox; changing one does not update the other.
If you skip the catalog while enabling plan changes, the portal cannot show honest upgrade targets. That is a configuration bug, not an app bug.
11 production screens. Login, database, payments — all wired.
The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.
Create a portal session from a privileged route
Stripe's entry point is a Billing Portal Session. Your authenticated "Manage billing" control should POST to your own server. The server looks up the Stripe Customer id for the signed-in workspace member, creates a session with that customer and a return_url back into your app, then redirects to the short-lived url on the session object (Integrate the customer portal; Create a portal session).
Illustrative shape only — match the kit's privileged Stripe routes after you clone; do not treat this as a dumped private filename:
// illustrative — privileged route after session auth
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function createBillingPortalSession(opts: {
stripeCustomerId: string;
returnUrl: string; // absolute URL into your signed-in settings page
}) {
const session = await stripe.billingPortal.sessions.create({
customer: opts.stripeCustomerId,
return_url: opts.returnUrl,
});
return session.url; // redirect the browser here — never invent a portal URL
}Hard rules that survive agent edits:
- Authenticate before creating a session. A portal session is a capability grant for that Stripe Customer.
- Persist the Stripe Customer id at Checkout time (or when the subscription is first created) on a server-owned table. Do not ask the browser to supply a customer id.
- Redirect only to
session.urlreturned by Stripe. Do not hardcode a portal hostname or paste a fake Checkout pay link into samples. - Keep secret keys and webhook signing secrets on the server. The kit's public claim is Checkout plus webhook confirmation on a privileged path — extend that same boundary for portal session creation.
Optional: pass a specific configuration id when different customer cohorts need different portal features. Stripe documents that override on session create; otherwise the default Dashboard configuration applies.

Webhooks keep entitlement aligned with the portal
The portal UI is not your source of truth. Stripe is. When a customer upgrades, downgrades, cancels, or changes a default payment method, your app must react through webhooks. Stripe's portal guide lists the events you should care about, including:
customer.subscription.updated— plan or quantity changes; grant or adjust product access from the price on the subscription itemscustomer.subscription.deleted— revoke access when the subscription ends- Cancel-at-period-end flows — listen for updates where cancellation is scheduled, then revoke only when the period actually ends (or when deleted fires), depending on how you configured the portal
payment_method.attached/payment_method.detachedandcustomer.updated— keep local billing profile fields in sync; do not treat billing email as a login credential (Integrate the customer portal)
On an owned SaaS kit this maps cleanly onto the existing purchase webhook path:
- Verify the Stripe signature on every event.
- Deduplicate by event id so retries do not double-apply entitlement (same lesson as the purchase idempotency post).
- Update workspace plan / seat / status columns from the subscription object — not from what the success page guessed.
- Keep agent prompts pointed at "extend the webhook handler," not "add a second billing table in chat."
If an agent proposes a client-only "cancel" button that flips a local canceled flag without Stripe, reject it. Local flags drift. Portal + webhooks do not.
Where the Manage billing button belongs in the kit
Public screen inventory for the SaaS kit includes Settings surfaces for profile, workspace, notifications, and appearance. That is the natural home for a Manage billing control that hits your portal-session route. The operator stays inside your product until redirect; Stripe renders plan change, card update, invoice history, and cancel UX; return_url brings them back to the same settings area.
Buyer checklist after clone:
- Env:
DATABASE_URL, auth secrets, Stripe secret key, webhook secret — fill from.env.examplethe kit documents - Confirm Checkout still creates a Customer + Subscription you can see in the Stripe Dashboard
- Store
stripe_customer_id(and subscription id if you need it) on the workspace or billing row your schema already uses - Add the authenticated portal-session route and a single Settings CTA
- Enable portal features in test mode, click through upgrade/cancel, watch webhook rows update entitlement
- Only then copy configuration to live mode and point production webhooks at the same handler shape
Agent session tip: load CLAUDE.md / .cursorrules first, then ask for a bounded change — "add Manage billing that creates a Billing Portal Session for the workspace's Stripe Customer and redirects to session.url." Do not ask the agent to redesign pricing tables from a blank chat while the portal catalog already exists in Stripe.
When to buy the kit versus staying on Checkout-only glue
Stay on a thin Checkout script when you have no recurring customers yet and cancellation is still a reply to an email. Prefer the SaaS kit plus Customer Portal when most of these are true:
- Operators already live in dashboard / issues / projects / teams screens you intend to keep
- Subscriptions are the commercial model, not one-off payments
- You want plan changes and card updates without building a billing UI
- Webhook-confirmed entitlement must match what Stripe shows for the Customer
- Maintenance happens in Cursor or Claude Code on a repository you own, with agent memory next to the code
Sandboxes remain useful for spikes. The durable seam is: Checkout for acquisition, Customer Portal for self-serve lifecycle, webhooks for access — all on privileged routes in a repo you can migrate.
Product page: SaaS Dashboard kit. Pricing: https://otf-kit.dev/pricing. For the broader greenfield framing, read How to build a SaaS app with AI. For webhook retry discipline, keep Stripe webhook idempotency open beside this page.
Sources
- Stripe: Integrate the customer portal
- Stripe: Create a Billing Portal Session
- OTF SaaS Dashboard kit
- OTF templates catalog
- OTF pricing
- OTF SaaS live demo
Ship the product, not the setup.
- 11 production screens — auth, billing, team, analytics, settings
- Real database, payments, and login — all wired on day 1
- AI configs pre-tuned so your agent extends instead of regenerates