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

> Wire Stripe Customer Portal on the SaaS Dashboard kit for self-serve plan changes, card updates, and cancels — Checkout acquires; webhooks keep access honest.
> By Dave · 2026-09-19
> Source: https://otf-kit.dev/blog/saas-kit-stripe-customer-portal

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](/blog/saas-kit-live-tour), not the greenfield path at [How to build a SaaS app with AI](/blog/how-to-build-a-saas-app-with-ai), and not the generic billing-ownership piece at [Auth and billing you don't hand-roll](/blog/auth-billing-you-dont-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](https://otf-kit.dev/templates/saas-dashboard) and in the [templates catalog](https://otf-kit.dev/templates). Standalone price is $99; it is also in the Everything Bundle on [https://otf-kit.dev/pricing](https://otf-kit.dev/pricing). Live proof before you buy sits at [https://saas.otf-kit.dev](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 under `ai/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](https://docs.stripe.com/customer-management/integrate-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.

![Authenticated app opens Stripe Customer Portal then returns — Luna, Byte, and Nova with a portal key](https://cdn.otf-kit.dev/blog/saas-kit-stripe-customer-portal/inbody1-20260919c.png)

## 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](https://docs.stripe.com/customer-management/integrate-customer-portal)).

Practical order on an owned SaaS repo:

1. 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](/blog/stripe-webhook-idempotency-saved-us).
2. 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).
3. 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.
4. 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.

## 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](https://docs.stripe.com/customer-management/integrate-customer-portal); [Create a portal session](https://docs.stripe.com/api/customer_portal/sessions/create)).

Illustrative shape only — match the kit's privileged Stripe routes after you clone; do not treat this as a dumped private filename:

```ts
// 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.url` returned 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.

![Webhook pulses for update, cancel, and card land as green checks on entitlement — Dex and Nova](https://cdn.otf-kit.dev/blog/saas-kit-stripe-customer-portal/inbody2-20260919b.png)

## 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 items
- `customer.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.detached` and `customer.updated` — keep local billing profile fields in sync; do not treat billing email as a login credential ([Integrate the customer portal](https://docs.stripe.com/customer-management/integrate-customer-portal))

On an owned SaaS kit this maps cleanly onto the existing purchase webhook path:

1. Verify the Stripe signature on every event.
2. Deduplicate by event id so retries do not double-apply entitlement (same lesson as the purchase idempotency post).
3. Update workspace plan / seat / status columns from the subscription object — not from what the success page guessed.
4. 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.example` the 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](https://otf-kit.dev/templates/saas-dashboard). Pricing: [https://otf-kit.dev/pricing](https://otf-kit.dev/pricing). For the broader greenfield framing, read [How to build a SaaS app with AI](/blog/how-to-build-a-saas-app-with-ai). For webhook retry discipline, keep [Stripe webhook idempotency](/blog/stripe-webhook-idempotency-saved-us) open beside this page.

## Sources

- [Stripe: Integrate the customer portal](https://docs.stripe.com/customer-management/integrate-customer-portal)
- [Stripe: Create a Billing Portal Session](https://docs.stripe.com/api/customer_portal/sessions/create)
- [OTF SaaS Dashboard kit](https://otf-kit.dev/templates/saas-dashboard)
- [OTF templates catalog](https://otf-kit.dev/templates)
- [OTF pricing](https://otf-kit.dev/pricing)
- [OTF SaaS live demo](https://saas.otf-kit.dev)
