A working billing portal has 3 pieces, and how to set up Stripe customer portal is mostly a question of the last two. Piece 1 is the Dashboard configuration. Piece 2 is a server endpoint that opens a portal session for the signed-in customer. And piece 3 is the webhook events that bring the customer’s change back into your app.

What is a self-serve billing portal?

A self-serve billing portal is a provider-hosted page where a signed-in customer does 4 things without writing to you: see and download invoices, change the card, change the plan, and cancel. Stripe’s version is the customer portal. It changes Stripe, not your database, so your app needs webhooks.

Stripe files it under customer management, in Stripe’s customer portal documentation, as a place where customers “manage their payment details, invoices, and subscriptions in one place”. The listed features are billing details and tax IDs, payment methods, subscription updates, cancellation (now or at the end of the current billing period), and paying, downloading and viewing current and past invoices. It is one control among the SaaS billing process best practices that a subscription app needs once real customers pay.

Stripe hosts it, so you build no billing screens of your own. Stripe’s no-code setup page lists its pricing as Stripe Billing pricing for recurring payments, or Invoicing pricing for an invoice-only setup; the current figures are on Stripe’s pricing page. Stripe’s API calls it the billing portal: sessions are created at /v1/billing_portal/sessions. It is a narrower thing than a customer portal in the general sense of a support or account area.

What the portal does not do is run your app. Stripe’s integration guide is direct about it: when subscriptions are upgraded, downgraded or canceled, you need to make sure customers receive only the products or services they’re actively subscribed to, and Stripe tells your integration about those changes through webhooks. Listening for them is piece 3, and it is your code. Once the event has landed, holding the account to its new plan is the job of how to enforce plan limits on the backend.

In the Production Hardening Sprint this is deliverable 5.8, where we “connect Stripe Customer Portal or the existing provider’s equivalent for invoices, card changes, and supported plan management”.

What goes wrong without it

When billing runs through the support inbox, each routine request turns into a small job for a person. The rows below are my reading of how that plays out for a small SaaS.

What the customer wantedWhat happens todayWhat it costs you
Change the cardAn email to support, then someone with Dashboard access swaps itCard details can land in an inbox, and the renewal can fail while the email waits
Get an invoiceA request for a PDF, often with a new address or tax numberFounder time at every month-end, plus invoices re-sent one at a time
Change the planAn email asking to move up or down, then an edit in StripeThe app and Stripe can disagree about the plan until someone fixes both
CancelA “please cancel” emailThe customer keeps paying until somebody replies, and remembers that

A portal that is only half set up fails in three quieter ways. The endpoint opens the portal for a customer id the browser sent, so one customer can open another’s billing. The customer changes plan in the portal and the app never hears about it, because nothing listens for the webhook. The customer downgrades and keeps everything the bigger plan allowed, because the app’s limits still read the old plan.

The first of those is a known class of bug. In my June and July 2026 audits, 7 of the 21 third-party apps had confirmed cross-user or cross-tenant authorization failures, where a logged-in user could read or write another customer’s data. Those 21 are the 11 public apps I audited across all 12 pillars and the 10 held-out apps I audited blind, a selected set rather than a random sample, so the count shows the failure is worth testing for, not how often it occurs in AI-built apps at large.

Told as a case, the failure looks like this. A founder’s “Manage billing” button sends the Stripe customer id the browser holds, and the server endpoint opens a portal session for whatever id arrives. A signed-in customer who changes that id in the request gets a portal session for another customer and sees whatever that customer’s portal is set to show: invoice history, payment methods, the subscription. The lesson I take from it is a rule: the customer id is looked up on the server, from the signed-in user, and never received from the browser.

Customers email support to change a card

Customers who email support to change a card are waiting on a person with Dashboard access, and card details should never travel by email. The portal’s payment method update removes that step, and a link in the failed-payment email can send the customer straight to it.

When customers email support to change card details, the work, as I see it, has three parts: someone who can log in to Stripe has to make the change, the customer may paste the full number into the message, and the renewal can fail in the hours before anyone gets to it. The portal’s Payment methods setting covers the whole request. Stripe describes it as “Let your customer update their payment method information”, and it is on by default. My suggestion is to put the portal link in the email a customer receives when a renewal fails, so the fix is one click from the problem; the email itself, and the retry and grace period around it, belong to how to handle failed subscription payments. None of this is the card issuer’s support line: it is your app’s own billing.

There is no way for users to download invoices

An app with no way for users to download invoices turns every month-end into a support request. The portal’s invoice history shows customers their invoices once the setting is on, and its billing-information settings let them update their own billing details.

The people I would expect to ask are business customers whose finance team needs a PDF every month with the right company address and tax number on it. Stripe’s “Invoice history visible” setting decides whether customers see their invoice history, and it is on by default. Under billing information, the billing address is editable by the customer by default and the tax ID is not until you turn it on; Stripe captures that information “to display on an invoice”. If you use Stripe Tax, the integration guide says to toggle on Tax ID in the customer portal settings, and Stripe Billing then adds the tax IDs to the customers’ invoices. An address edited today does not change invoices already issued: Stripe’s invoicing docs say finalizing an invoice copies the customer’s address and tax IDs to it and makes them immutable. When the app itself has no invoice screen, this setting is the screen.

How to set up Stripe customer portal: settings, the session endpoint, the webhooks

The Stripe customer portal is set up in 3 pieces: the Dashboard settings that decide what customers may change, a server endpoint that opens a portal session for the signed-in customer, and the webhook events that carry each change back into your app. Skipping the third leaves the app wrong.

The procedure below is taken from Stripe’s documentation as published on 3 October 2026, not from a portal I set up for this page. The three pieces come first, then the two decisions every portal forces (downgrades and cancellation), then what changes on Paddle or Lemon Squeezy.

Piece 1: the portal settings in the Dashboard

No code is needed for this part. In the Stripe Dashboard, open Settings, then Billing under the product settings, then Customer portal. Stripe’s portal configuration page lists every option with its default; these are the seven to decide before a customer sees the portal.

  1. 01 Switch plan, off by default: turn it on and choose the products and prices customers may move between.
  2. 02 Update quantities, off by default: turn it on only if you sell seats.
  3. 03 Payment methods, on by default: leave it on so customers replace their own card.
  4. 04 Invoice history visible, on by default: leave it on so customers find past invoices themselves.
  5. 05 Billing information: billing address is on by default and tax ID is off; turn tax ID on if business customers need it on invoices.
  6. 06 Cancel subscription, on by default, with cancellation reason on and retention coupons off.
  7. 07 The headline, terms of service link, default redirect link back to your app and business name, plus logo and colors in the branding settings.

Stripe’s integration guide says to choose your settings “for sandboxes and live mode”, and Stripe keeps separate portal configurations for live mode and for each sandbox, so a change in one does not touch the other. That means the settings are done twice, and copying them into live mode is a line on the Stripe go-live checklist. The sandbox portal is also your demo: after saving, open a sandbox customer in the Dashboard, click Actions, then select Open customer portal.

Piece 2: a server endpoint that opens the session

A portal session is created on the server for one customer: the Stripe customer id stored against the signed-in user in your own database. The id never comes from the request, or one customer can open another’s billing. The secret key never reaches the browser.

The flow in Stripe’s portal integration guide has four steps.

  1. The signed-in customer clicks “Manage billing”, a button that sends a POST request to your server.
  2. Your server reads the user from its own session and looks up that user’s Stripe customer id in your database.
  3. It creates a portal session with that customer id and a return_url, which is required if no default return URL is set in the Dashboard configuration.
  4. It redirects to the session’s url, which Stripe calls “the session’s short-lived URL”.
// POST /billing/portal: open the signed-in user's own portal
app.post('/billing/portal', requireUser, async (req, res) => {
  const account = await db.billingAccounts.findByUserId(req.user.id);
  if (!account?.stripeCustomerId) return res.status(404).end();
  const session = await stripe.billingPortal.sessions.create({
    customer: account.stripeCustomerId, // from your database, never from req.body
    return_url: 'https://yourapp.example/settings/billing',
  });
  res.redirect(session.url);
});

The parameter is customer if your app uses Stripe’s Customer objects; on the Accounts v2 API, generally available for Connect users and in public preview for other Stripe users, it is customer_account. Create a fresh session on every click and never store the URL: a new session expires after five minutes if nobody uses it, and a used one within an hour of the last activity. Stripe’s own instruction is to authenticate customers on your site before creating sessions for them. My rule goes one step further: the customer id comes from the session’s user, never from the request body or a query string. The secret key that signs the call stays on the server as well. Across the same 21 third-party apps I audited in June and July 2026, a selected set and not a random sample, 6 shipped a real secret.

On a Supabase or builder-made app, this endpoint can live in an Edge Function. Supabase Edge Functions verify a Supabase JWT by default, and this one should keep that default, because a signed-in user calls it; read the user id from the verified token. On a Lovable app, a provider’s portal “will not function inside the Lovable preview panel”, as Lovable integrations records, so test it on the deployed site.

There is also the no-code customer portal: you activate a login link, and customers can “log in to the portal with their email address”, after which Stripe emails them a link that opens their session. If several customers share that email, Stripe selects the most recently created one that has both the email and an active subscription. The trade-off, in my reading, is visibility: the session starts from Stripe’s email, not from your app, and Stripe’s docs do not say whether the billing_portal.session.created event fires for these login-link sessions. The integration guide adds that billing email changes are billing information only: don’t use the customer billing email address as a login credential.

Piece 3: the webhooks that bring the change back

Portal changes reach your app through webhook events. A plan change, a quantity change or a scheduled cancellation arrives as customer.subscription.updated, and an ended subscription as customer.subscription.deleted. The handler reads the subscription’s current state from each event and updates the app’s record.

The integration guide maps each portal action to the event it sends and the attribute to read.

Portal actionStripe eventWhat your handler checks
Plan upgraded or downgradedcustomer.subscription.updatedsubscription.items.data[0].price, then grant, adjust or revoke access
Quantity changedcustomer.subscription.updatedsubscription.items.data[0].quantity
Cancellation scheduled for the period end, or reactivatedcustomer.subscription.updatedcancel_at on flexible billing mode, cancel_at_period_end on classic billing mode
Subscription endedcustomer.subscription.deletedRevoke the customer’s access to the product
Card added, removed or made the defaultpayment_method.attached, payment_method.detached, customer.updatedinvoice_settings.default_payment_method on the customer

One more line from the same guide: when a customer uses the portal to upgrade or downgrade a subscription with a trial, the trial ends immediately. Stripe’s overview page calls that the default; setting features.subscription_update.trial_update_behavior to continue_trial in the portal configuration keeps the trial active. I would not treat the return URL as a substitute for any of this, because the customer can close the tab before it loads. The handler should be the same signature-verified one that processes checkout, and it has to be idempotent: Stripe can retry failed live webhook deliveries for up to three days and does not guarantee event order.

My rule after that: the handler writes the subscription’s current state, never a change on top of the last one. The full event map, from checkout to cancellation, is the subscription lifecycle in Stripe.

Downgrades: when the change takes effect, and what happens to usage over the new limit

A downgrade needs 3 decisions made before a customer clicks it: whether it takes effect now or at the period end, whether the switch is prorated, and what the app does with usage above the new limit. My rule: block new items and never delete the customer’s data.

The first two decisions are portal settings; the third is your app’s.

DecisionStripe’s setting and defaultMy usual choice for a small SaaS
When a downgrade takes effectManage downgrades, default “Update immediately”; can instead schedule the change for the end of the billing period, only between prices that have the same productEnd of the billing period, so the customer keeps what they paid for
How the switch is billedProrate subscription updates, off by default; can credit time remaining, applied immediately or at the end of the billing periodOn, so a mid-period change credits the unused time
Usage above the new limitNot stated in Stripe’s docsBlock new projects or seats, delete nothing, show what to remove

The third column is my working rule, not a Stripe default. Stripe’s proration documentation explains the arithmetic: a change mid-period credits unused time on the old price and charges for the remaining time on the new one, calculated down to the second by default. It also warns that “Negative prorations aren’t automatically refunded and positive prorations aren’t immediately billed, although you can do both manually.” A scheduled downgrade has a side effect worth knowing: customers can’t update or cancel a subscription while it has an update scheduled with a subscription schedule. The rule for an account that now holds more than its plan allows is an entitlement rule, and it lives with enforcing plan limits on the backend. Whatever you choose, state it on the pricing page or in the terms, so the portal’s behavior is no surprise.

Cancellation in the portal, and providers other than Stripe

Stripe’s configure page describes the Cancel subscription option this way: “After canceling, customers can still renew subscriptions until the billing period ends.” Before the customer confirms, the portal can ask why they are leaving and offer a coupon to stay; the first is on by default and the second is not. When the app and Stripe disagree after a cancellation, the diagnosis is in a canceled Stripe subscription that still has access. Refunds, complimentary access and disputes stay with a person, written down in the billing operations runbook checklist.

If you bill through Paddle or Lemon Squeezy, the same job has a hosted answer. Paddle’s customer portal, in the Paddle Billing docs, is included by default and lets customers see past payments and download invoices, manage their subscriptions and update their payment details; they sign in through a link Paddle emails them, and your app can generate authenticated links so a signed-in customer skips that step. Lemon Squeezy’s customer portal lives at your store’s /billing address and lets customers switch between subscription products, pause or cancel and resume, manage payment methods, and update their billing information and tax ID; the API gives a signed URL that logs the customer straight in. In my reading, pieces 2 and 3 carry over under other names: the authenticated or signed link is the session endpoint, and the provider’s webhooks still have to update your app.

How to verify it: test a plan change from the billing portal, then the downgrade billing logic

A billing portal is verified with 5 scenarios in a sandbox: the portal opens for the right customer only, a card change becomes the default payment method, an invoice downloads, an upgrade unlocks the feature in the app, and a downgrade bills and restricts as the settings say.

Run everything in a Stripe sandbox, with two test users in the app (A and B), each tied to its own test customer. Create B’s customer inside a Stripe simulation, because the Dashboard cannot add an existing customer to a simulation and scenario 5 needs its clock.

  1. 01 Open the portal from the app as user A and confirm it shows A's customer. Then, signed in as user B, send the session request with A's customer id in the body or query string, and confirm that the portal which opens is B's own or that the request is refused. Evidence: both session responses.
  2. 02 Change the card to another Stripe test card and confirm the customer's default payment method changed in Stripe and, if your app keeps a copy of billing details, in that record too. Evidence: the customer.updated event id.
  3. 03 Download an invoice PDF from the portal's invoice history and confirm the billing address and tax ID on it. Evidence: the PDF.
  4. 04 Test a plan change from the billing portal (Switch plan must be on, since it starts off): upgrade, then confirm the event arrived, the app's subscription row changed and the paid feature unlocked without anyone editing the database. Evidence: the event id and the row before and after.
  5. 05 Test the subscription downgrade billing logic: downgrade, confirm when it takes effect against the Manage downgrades setting, advance the simulation clock past the period end, and confirm the invoice amount, any proration line and that the app enforces the smaller plan. Then cancel, and with cancellation at the period end confirm access lasts until that date and stops after it. Evidence: the invoice id, the row and the date.
ScenarioIn the portalIn StripeIn the app
1. Right customerA sees A; B sees only BEach session belongs to the requester’s customerThe server ignored the id in the request
2. Card changeNew card shown as defaultcustomer.updated with a new invoice_settings.default_payment_methodStored billing copy updated, if you keep one
3. InvoicePDF downloads from invoice historyInvoice carries the billing address and tax IDNo support request needed
4. UpgradeNew plan showncustomer.subscription.updated with the new priceRow updated, feature unlocked
5. Downgrade, then cancelChange shown as now or at period endInvoice and any proration line after the clock movesSmaller plan enforced; access ends after the period

Stripe test clocks move a monthly subscription up to two months per step, take a few seconds to advance, and send test_helpers.test_clock.ready when they arrive. Keep the matrix with the date of each run, the test customer ids and the event ids; store it outside Stripe, because finishing a simulation deletes its customer and subscription from the sandbox. Testing the webhook handler on its own, with repeated and out-of-order events, is a separate method from this portal check.

In the sprint, deliverable 5.8 is verified this way: “test an authenticated customer’s portal access and resulting updates in the application”.

Where the sprint does this

The result of that check is written into the production readiness report, deliverable 13.1, which accounts for all 123 IDs, keeps failures visible until resolved and explains genuine non-applicable items. Building new product features or modules is outside the sprint; new features and completing unfinished core workflows are separate work. Deliverable 5.8 is listed in the published scope, payments area, and 13.1 in its handover area.

Common questions about customer billing portals

Does Stripe offer a customer portal?

Yes. Stripe hosts a customer portal where your customers manage their payment details, invoices and subscriptions, configured in the Dashboard or through the API. Stripe prices it with Stripe Billing for recurring payments, and the current rates are on its pricing page.

How to create a customer portal?

On Stripe, Paddle or Lemon Squeezy you use the provider’s hosted portal, open it from your app for the signed-in customer, and listen for the webhooks it causes. Paddle includes its portal by default, and Lemon Squeezy serves one at your store’s billing address. Building your own means rebuilding card handling, which is my reason the hosted portal is the default choice.

Can you provide an example of a self-service portal?

Stripe’s hosted customer portal is a common example. Picture a customer of a small project-tracking app: they click Manage billing, land on Stripe’s portal, download last month’s invoice for their accountant, replace a card that is about to expire, and move to the next plan up, which the portal settings allow. The app learns about the plan change from the customer.subscription.updated event and unlocks the bigger plan.

What is a customer portal?

A customer portal is any signed-in area where customers help themselves: support tickets, documents, orders or account settings. A billing portal is the part of it that handles money, meaning invoices, cards, plans and cancellation.

How to register in customer portal?

You don’t register for Stripe’s portal. With the API flow, the app’s own sign-in opens it, because your server creates the session for the signed-in customer. With Stripe’s no-code link, the customer enters their billing email and Stripe emails them a login link that opens their session.