How to Build a SaaS Billing Portal — Stripe Customer Portal vs Custom Implementation

Every SaaS founder eventually faces the same question: "How do I let my customers change their plan, update their card, or cancel without emailing me?" The answer used to be "build a billing portal," which took a team weeks. Then Stripe launched the Customer Portal, and the answer became "use Stripe's."
But the hosted portal has limits. You cannot fully control the branding, the flow is Stripe's, not yours, and if you need custom cancellation retention logic or a non-standard upsell path, you hit a wall.
We have built both approaches across enough SaaS projects to know when each makes sense. This post implements both — the hosted Stripe Customer Portal and a custom billing UI — with the tradeoffs, the code, and the decision framework.
If you are staring at a "Billing Settings" page in your product backlog right now — this post is for you.

Option 1: Stripe Customer Portal
The hosted Customer Portal takes about an hour to set up. You configure it in the Stripe Dashboard, generate a session link from your backend, and redirect the user. That is it.
Configuration in Stripe Dashboard
Go to Settings > Billing > Customer Portal in your Stripe Dashboard. The configuration screen lets you control:
- Products and prices — which plans customers can switch between.
- Payment method updates — whether customers can change their card.
- Cancellation — whether customers can cancel, and whether you want to collect a reason.
- Invoice history — whether past invoices are visible.
Enable what you need, leave everything else off. Every toggle you enable adds complexity to the portal that you cannot remove per-customer without using the API.
Backend: Create a Portal Session
1// billing.service.ts
2import { Injectable } from "@nestjs/common"
3import Stripe from "stripe"
4
5@Injectable()
6export class BillingService {
7 private stripe: Stripe
8
9 constructor() {
10 this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
11 apiVersion: "2025-12-15",
12 })
13 }
14
15 async createPortalSession(
16 customerId: string,
17 returnUrl: string
18 ): Promise<string> {
19 const session = await this.stripe.billingPortal.sessions.create({
20 customer: customerId,
21 return_url: returnUrl,
22 })
23
24 return session.url
25 }
26}1// billing.controller.ts
2import { Controller, Post, UseGuards, Req, Body } from "@nestjs/common"
3import { BillingService } from "./billing.service"
4
5@Controller("billing")
6export class BillingController {
7 constructor(private readonly billing: BillingService) {}
8
9 @Post("portal")
10 async portal(@Body() body: { customerId: string; returnUrl: string }) {
11 const url = await this.billing.createPortalSession(
12 body.customerId,
13 body.returnUrl
14 )
15 return { url }
16 }
17}The return_url is where the user lands after they finish in the portal. It should be your billing settings page.
What the Hosted Portal Cannot Do
The hosted portal is fast to integrate but has hard limits:
- No custom branding beyond logo and accent colour. You cannot control the layout, the typography, or the page structure.
- No custom cancellation flow. You can collect a reason, but you cannot show a "would you like to pause instead?" offer before the cancellation completes.
- No upsell cross-sell. The portal only shows the plans you configure. You cannot dynamically offer an annual discount when someone tries to downgrade.
- No per-customer configuration. Every customer sees the same portal configuration.
For most early-stage SaaS products, none of these limits matter. The hosted portal handles 90% of what customers need. But if you are past that stage, you need the custom approach.

Option 2: Custom Billing Portal
Building your own billing portal means you control every pixel, every flow, and every business rule. It also means you have to handle plan changes, payment method updates, invoice history, and cancellations yourself.
Fetching the Current Subscription
1// subscription.service.ts
2import { Injectable } from "@nestjs/common"
3import Stripe from "stripe"
4
5@Injectable()
6export class SubscriptionService {
7 private stripe: Stripe
8
9 constructor() {
10 this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
11 apiVersion: "2025-12-15",
12 })
13 }
14
15 async getSubscription(customerId: string) {
16 const subscriptions = await this.stripe.subscriptions.list({
17 customer: customerId,
18 status: "active",
19 limit: 1,
20 })
21
22 const sub = subscriptions.data[0]
23 if (!sub) return null
24
25 const price = sub.items.data[0].price
26 const product = await this.stripe.products.retrieve(
27 price.product as string
28 )
29
30 return {
31 id: sub.id,
32 planName: product.name,
33 amount: price.unit_amount,
34 currency: price.currency,
35 interval: price.recurring?.interval,
36 currentPeriodEnd: new Date(sub.current_period_end * 1000),
37 status: sub.status,
38 }
39 }
40}Your app should also store the subscription state in your own database, synced via webhooks. Querying Stripe on every page load works for low traffic but does not scale. Sync subscription data to your database when customer.subscription.updated fires, and read from your own tables.
Plan Upgrade and Downgrade With Proration
1// subscription.controller.ts
2import { Controller, Post, Body, UseGuards } from "@nestjs/common"
3import Stripe from "stripe"
4
5@Controller("billing")
6export class SubscriptionController {
7 private stripe: Stripe
8
9 constructor() {
10 this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
11 apiVersion: "2025-12-15",
12 })
13 }
14
15 @Post("change-plan")
16 async changePlan(
17 @Body() body: { subscriptionId: string; newPriceId: string }
18 ) {
19 const subscription = await this.stripe.subscriptions.retrieve(
20 body.subscriptionId
21 )
22
23 const updated = await this.stripe.subscriptions.update(
24 body.subscriptionId,
25 {
26 items: [
27 {
28 id: subscription.items.data[0].id,
29 price: body.newPriceId,
30 },
31 ],
32 proration_behavior: "create_prorations",
33 billing_cycle_anchor: "unchanged",
34 }
35 )
36
37 return { status: updated.status, currentPeriodEnd: updated.current_period_end }
38 }
39}Proration is set via proration_behavior: "create_prorations". Stripe calculates the credit for unused time on the old plan and the charge for the new plan, and generates an invoice for the difference. The billing_cycle_anchor: "unchanged" keeps the billing date the same rather than resetting it.
On the frontend, show the prorated amount to the user before they confirm the change:
1async function previewProration(
2 subscriptionId: string,
3 newPriceId: string
4): Promise<{ immediateAmount: number; credit: number }> {
5 const res = await fetch("/api/billing/preview-proration", {
6 method: "POST",
7 headers: { "Content-Type": "application/json" },
8 body: JSON.stringify({ subscriptionId, newPriceId }),
9 })
10 return res.json()
11}Payment Method Management
Customers need to update their card without losing their subscription. Stripe's SetupIntents API lets you collect a new payment method without immediately charging it:
1// payment.controller.ts
2import { Controller, Post, Body, UseGuards } from "@nestjs/common"
3import Stripe from "stripe"
4
5@Controller("billing/payment")
6export class PaymentController {
7 private stripe: Stripe
8
9 constructor() {
10 this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
11 apiVersion: "2025-12-15",
12 })
13 }
14
15 @Post("setup-intent")
16 async createSetupIntent(@Body() body: { customerId: string }) {
17 const intent = await this.stripe.setupIntents.create({
18 customer: body.customerId,
19 payment_method_types: ["card"],
20 })
21
22 return { clientSecret: intent.client_secret }
23 }
24}On the frontend, use Stripe Elements or Stripe.js to collect the card details and confirm the SetupIntent. Stripe attaches the new payment method to the customer and — if configured — sets it as the default for future invoices.
Always show the last four digits and expiry of the current card on file before the user enters a new one, so they know what they are replacing.
Invoice History and PDF Downloads
1// invoice.controller.ts
2import { Controller, Get, Param } from "@nestjs/common"
3import Stripe from "stripe"
4
5@Controller("billing/invoices")
6export class InvoiceController {
7 private stripe: Stripe
8
9 constructor() {
10 this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
11 apiVersion: "2025-12-15",
12 })
13 }
14
15 @Get(":customerId")
16 async listInvoices(@Param("customerId") customerId: string) {
17 const invoices = await this.stripe.invoices.list({
18 customer: customerId,
19 limit: 50,
20 })
21
22 return invoices.data.map((inv) => ({
23 id: inv.id,
24 amount: inv.total,
25 currency: inv.currency,
26 status: inv.status,
27 date: new Date(inv.created * 1000),
28 pdfUrl: inv.invoice_pdf,
29 number: inv.number,
30 }))
31 }
32}The invoice_pdf field on each invoice object contains a hosted PDF URL. Use it directly for download links. These URLs are accessible without authentication because Stripe generates them per-invoice. If you want to restrict access, proxy the download through your backend and verify the user's session before streaming the PDF.

Cancellation Flow With Retention Offer
Roughly a quarter of SaaS churn is involuntary — failed payments and expired cards, not customers who decided to leave (industry benchmarks, 2024) — and a self-service billing portal quietly kills most of that just by letting people fix their own card. The churn you can still fight is the voluntary kind, and the cheapest tool for it is a retention offer shown before the cancellation completes. The simplest offer is a discount on the current plan:
1// cancellation.controller.ts
2@Controller("billing")
3export class CancellationController {
4 private stripe: Stripe
5
6 constructor() {
7 this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
8 apiVersion: "2025-12-15",
9 })
10 }
11
12 @Post("cancel")
13 async cancelSubscription(
14 @Body() body: { subscriptionId: string; reason?: string }
15 ) {
16 const subscription = await this.stripe.subscriptions.update(
17 body.subscriptionId,
18 {
19 cancel_at_period_end: true,
20 metadata: {
21 cancellation_reason: body.reason || "not_provided",
22 },
23 }
24 )
25
26 return {
27 status: subscription.status,
28 effectiveEnd: new Date(subscription.current_period_end * 1000),
29 }
30 }
31
32 @Post("apply-retention-offer")
33 async applyRetentionOffer(
34 @Body() body: { subscriptionId: string; couponId: string }
35 ) {
36 const subscription = await this.stripe.subscriptions.update(
37 body.subscriptionId,
38 {
39 cancel_at_period_end: false,
40 discounts: [{ coupon: body.couponId }],
41 }
42 )
43
44 return { status: subscription.status, discount: "20% off for 3 months" }
45 }
46}The retention flow works like this:
- User clicks "Cancel."
- Show a modal: "We would love to keep you. Get 20% off your next 3 months."
- If they accept, apply a coupon via the API and set
cancel_at_period_end: false. - If they decline, set
cancel_at_period_end: trueand log the reason.
Track which users accepted retention offers and whether they churned later anyway. If the 3-month discount expires and they cancel immediately, the retention offer merely delayed the churn — you need a different retention strategy for that cohort.
Reactivation After Cancellation
When a subscription cancels, it either ends immediately or at the period end depending on your Stripe settings. Either way, the customer can resubscribe:
1// Method on a billing controller (this.stripe is initialized in the constructor)
2@Post("reactivate")
3async reactivate(
4 @Body() body: { customerId: string; priceId: string }
5) {
6 const subscription = await this.stripe.subscriptions.create({
7 customer: body.customerId,
8 items: [{ price: body.priceId }],
9 off_session: true,
10 })
11
12 return { status: subscription.status, id: subscription.id }
13}Note the off_session: true parameter. This tells Stripe to attempt charging the customer's default payment method without redirecting them to a checkout page. If the card on file succeeds, the subscription activates immediately. If it fails, Stripe sends an email to the customer asking them to update their payment method.
Decision Framework
| Factor | Use Stripe Customer Portal | Build Custom Portal |
|---|---|---|
| Time to ship | One afternoon | 2-3 weeks |
| Brand control | Logo + accent colour only | Full control |
| Custom cancellation flow | None (reason collection only) | Full retention offers |
| Upsell/cross-sell | Plan changes only | Custom offers |
| Invoice download | Built in | Manual implementation |
| Maintenance | Stripe handles it | You own it |
| Billing complexity | Standard plans | Any model |
Start with the hosted Customer Portal. It ships in a day and handles the vast majority of customer needs. Build a custom portal only when the hosted version blocks a specific flow your business needs — custom retention, non-standard pricing, or deep brand integration.
For more on Stripe webhook handling, see our guide on Stripe subscription billing in NestJS. And for the authentication layer that protects your billing endpoints, our API key authentication guide covers the guard pattern.
The Stripe Customer Portal documentation covers configuration and API. The Stripe subscription update API covers proration and plan changes.
The billing portal is not a feature your users love using. It is a feature they notice only when it is missing — when they cannot update their card, when they have to email you to change plans, when they want a PDF of an invoice for their accountant. Make it work, make it boring, and get back to building the product they actually pay for.
Frequently Asked Questions
The Stripe Customer Portal is a Stripe-hosted billing management page that lets customers update payment methods, upgrade or downgrade plans, cancel subscriptions, and view invoice history. You configure it in the Stripe Dashboard and integrate it via a single API call that generates a temporary session link.
Use the hosted Customer Portal if you want a fast, secure, Stripe-maintained solution with minimal engineering effort. Build a custom billing UI if you need full brand control, custom cancellation flows with retention offers, or billing logic that differs from Stripe's hosted capabilities.
Stripe handles proration automatically when you update a subscription via the API. Use the proration_behavior parameter to control whether credits are applied. Stripe calculates the prorated amount and generates a credit note or invoice. The Customer Portal handles this out of the box — custom implementations need to call the subscription update endpoint manually.
For the Stripe Customer Portal, enable the invoice history feature in the Dashboard settings and it is included automatically. For a custom billing portal, use the Stripe Invoices API to list the customer's invoices and provide a download link to the invoice PDF using the invoice_pdf field from the invoice object.
When a customer cancels, Stripe sets the subscription status to 'canceled' and the subscription ends at the current period's end. Your webhook handler receives the customer.subscription.deleted event. For custom portals, you can add a retention flow — show a discount offer before confirming cancellation to reduce involuntary churn.
