Stripe Customer Portal Setup for SaaS: Self-Service Subscription Management

26 May 2026 · Bank K.

Set up the Stripe Customer Portal so users can update cards, swap plans, and download invoices themselves. Full code walkthrough with webhooks.

A user emails you at 11pm. Their card expired. Can you update it for them? Another user wants to cancel. Another wants to swap from monthly to yearly. Another wants the PDF invoice for last month. You are not running a SaaS at this point — you are running a billing help desk.

The Stripe Customer Portal solves this. It is a Stripe-hosted page where your customers manage their own subscriptions, payment methods, invoices, and tax IDs. You add a single button to your app. Stripe handles the rest, including PCI compliance and the UI.

This post walks through setting it up: configuring the portal in the Stripe Dashboard, creating portal sessions from your backend, redirecting users, and listening for webhook events so your database stays in sync. If you have already followed the Stripe payments setup for Next.js, this is the natural next step.

What the Customer Portal Actually Does

When a logged-in customer clicks “Manage subscription” in your app, they land on a Stripe-hosted page. From there they can:

  • Update their payment method (new card, switch to a different one on file)
  • Switch plans (upgrade, downgrade, change billing interval)
  • Cancel their subscription (immediate or at period end)
  • Download invoices and receipts as PDFs
  • Update billing email and tax ID
  • View upcoming invoice and proration preview

You do not write any of that UI. You do not handle card data. You write one route that creates a session and redirects.

Step 1: Configure the Portal in the Dashboard

Before you write any code, configure what the portal exposes. Go to the Stripe Dashboard -> Settings -> Billing -> Customer Portal (or the direct URL dashboard.stripe.com/settings/billing/portal).

The configuration page has sections for:

  • Business information: Headline, privacy policy URL, terms URL. These show at the top of the portal.
  • Functionality: Toggle invoice history, payment method updates, billing address updates.
  • Subscriptions: Toggle cancellation, plan switching, quantity changes. If you allow cancellation, choose whether it takes effect immediately or at period end. Period end is almost always what you want — it gives the user the time they already paid for and reduces refund disputes.
  • Products: For plan switching, select which products and prices customers can switch between. If you have a Pro plan at $19 and a Pro plan at $190/year, list both here so users can swap intervals.

Save the configuration. The Dashboard creates a default configuration that the API uses unless you specify another one.

Step 2: Install the Stripe SDK

If you have not already:

npm install stripe

In your backend, initialize the client. This works for Next.js API routes, Astro endpoints, SvelteKit actions — any Node-compatible backend:

// lib/stripe.js
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: '2025-04-30.basil'
});

Use your test mode secret key during development. Switch to live mode keys when you ship.

Step 3: Store the Stripe Customer ID

The portal needs a Stripe customer ID to create a session. You should already be storing this on your user record when they first subscribe. If you are not, fix that first. A user row in your database needs a stripe_customer_id column.

When the user signs up for a subscription via Checkout, Stripe creates a Customer object. Save the ID in your checkout.session.completed webhook handler:

// In your webhook handler
if (event.type === 'checkout.session.completed') {
  const session = event.data.object;
  await db.user.update({
    where: { email: session.customer_email },
    data: { stripeCustomerId: session.customer }
  });
}

Without this ID, you cannot create a portal session. Without it on every paying user, the portal button is broken for some accounts.

Step 4: Create a Portal Session Endpoint

This is the core of the integration. Create one endpoint that creates a portal session and returns the URL. The example below uses Next.js App Router, but the logic is identical anywhere.

// app/api/portal/route.js
import { stripe } from '@/lib/stripe';
import { auth } from '@/lib/auth';
import { NextResponse } from 'next/server';

export async function POST(request) {
  const user = await auth(request);
  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  if (!user.stripeCustomerId) {
    return NextResponse.json(
      { error: 'No subscription found' },
      { status: 400 }
    );
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.APP_URL}/dashboard/billing`
  });

  return NextResponse.json({ url: session.url });
}

Three things to note here. First, always authenticate the user before creating a session — a portal session URL grants full access to that customer’s billing, so handing one to the wrong person is a privacy bug. Second, the return_url is where Stripe sends the user when they click “Return to {your app}” — usually your billing page or dashboard. Third, the session URL is short-lived (single-use, expires in a few minutes), so do not cache it.

Step 5: Wire Up the Frontend Button

On the frontend, a single button POSTs to the endpoint and redirects:

'use client';

export function ManageBillingButton() {
  async function openPortal() {
    const res = await fetch('/api/portal', { method: 'POST' });
    const { url } = await res.json();
    window.location.href = url;
  }

  return (
    <button onClick={openPortal} className="btn-primary">
      Manage subscription
    </button>
  );
}

That is the whole UI. No forms, no card inputs, no invoice list. Stripe builds all of that.

Step 6: Listen for Webhooks

The portal does not call your app when a user changes something. It tells Stripe, and Stripe fires webhook events. Your database needs to listen for these so the user’s plan, status, and access stay accurate.

The events you care about for portal-driven changes:

  • customer.subscription.updated — plan changed, quantity changed, or status changed
  • customer.subscription.deleted — subscription ended (either canceled or final period ended)
  • invoice.payment_succeeded — recurring renewal charged successfully
  • invoice.payment_failed — renewal failed, smart retries kicking in
  • customer.updated — billing details changed (email, address, tax ID)

A minimal webhook handler:

// app/api/webhooks/stripe/route.js
import { stripe } from '@/lib/stripe';
import { headers } from 'next/headers';

export async function POST(request) {
  const body = await request.text();
  const signature = headers().get('stripe-signature');

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    return new Response('Invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'customer.subscription.updated':
    case 'customer.subscription.deleted': {
      const sub = event.data.object;
      await db.user.update({
        where: { stripeCustomerId: sub.customer },
        data: {
          plan: sub.items.data[0].price.lookup_key,
          status: sub.status,
          currentPeriodEnd: new Date(sub.current_period_end * 1000)
        }
      });
      break;
    }
    case 'invoice.payment_failed': {
      // Optional: email the user that their payment failed
      break;
    }
  }

  return new Response('ok', { status: 200 });
}

If you skip this, your app will show a user as on the Pro plan after they downgrade to Basic in the portal. Or you will still grant access to a customer who canceled. The portal is the source of truth for billing state, but your database is the source of truth for application access — the webhooks are the bridge.

If you have not set up webhooks before, the Stripe webhooks guide for Next.js covers the verification, idempotency, and local development setup in detail.

Step 7: Test in the Dashboard

Test mode has a full portal too. Create a test customer with an active subscription, then:

  1. Manually create a portal session in the Stripe Dashboard (Customers -> select customer -> ”…” menu -> “Customer portal”)
  2. Walk through each action: update card, switch plan, cancel
  3. Watch your webhook events fire in the Dashboard’s Events tab
  4. Confirm your database updates correctly

Use test card 4242 4242 4242 4242 for new cards, and 4000 0000 0000 0341 for one that triggers a payment failure on the next renewal.

If you want to skip the boilerplate entirely — the customer record, the webhook plumbing, the access checks — Beag.io ships with auth and Stripe billing wired up out of the box. The portal endpoint is one of the routes that comes pre-built.

Common Issues and Fixes

“No such customer” error: The stripe_customer_id you stored does not exist in the current mode. Most likely you saved a test customer ID and are now hitting live mode (or vice versa). Each mode has its own customer namespace.

Cancellation does not revoke access: You are probably checking user.status === 'active' but not handling 'canceled' or expired currentPeriodEnd. Decide your policy — usually access remains until period end, then expires.

Plan switch shows wrong price: The portal uses the prices you listed in the Dashboard configuration. If you added a new price, return to Settings -> Customer Portal and add it to the allowed products list.

Portal redirects back immediately: Your return_url is probably the same page that triggers the portal, creating a loop. Send users to a dedicated billing settings page instead.

What You Gain by Adding This

A working customer portal removes the most common SaaS support tickets: card updates, cancellation requests, invoice downloads, plan changes. For a solo founder, each of those costs ten to twenty minutes by the time you find the customer, verify their identity, and update Stripe. Add the portal once and those tickets disappear.

You also get better retention. Users who can downgrade themselves often do — and stay subscribed — instead of canceling because there is no easier path. Stripe’s own data on self-service billing supports this pattern across thousands of SaaS businesses.

The portal is one of those features that takes a couple of hours to set up and saves you weeks of support work over the first year.

FAQ

Do I need a paid Stripe plan to use the customer portal? No. The portal is included with the standard Stripe Billing pricing — you pay the same per-transaction fees you already pay for Checkout. There is no separate subscription fee for the portal itself.

Can I customize the portal’s look? You can set the logo, brand color, accent color, and font from your Stripe Dashboard branding settings. You cannot change the layout or add custom fields. If you need full UI control, build your own billing UI against the Stripe API instead.

What happens if a customer cancels through the portal? By default Stripe sets cancel_at_period_end = true and the subscription ends naturally when the current paid period ends. The customer.subscription.updated webhook fires immediately, and customer.subscription.deleted fires at the actual end date. Your access logic should read from these events.

Can users on the free plan access the portal? Only if they have a Stripe customer record. If you only create the customer when they first pay, free users will not have a stripe_customer_id and the button should be hidden for them. If you create the customer at signup, the portal works but will show no subscriptions until they upgrade.

How do I let users add a new subscription from the portal? The portal does not currently support adding net-new subscriptions — only managing existing ones. For new sign-ups, send users to Checkout instead. For upgrades from a free plan, the cleanest UX is a “Choose a plan” page that creates a Checkout Session.

About the Author
Bank K.

Bank K.

Serial entrepreneur & Co-founder of Beag.io

Founder of Beag.io. Indie hacker building tools to help developers ship faster.

Ready to Make Money From Your SaaS?

Turn your SaaS into cash with Beag.io. Get started now!

Start 7-day free trial →