When you have under 50 paying customers, "update my card", "can you send the invoice", and "I want to cancel" still land in your inbox. You open the Stripe Dashboard, find the customer, click around, paste a screenshot, apologize for the delay. It works. It's also a bad habit that gets worse every week you wait.
Stripe's Customer Portal is the cheap offload for that size. Hosted UI. You configure which features are on. Customer does the billing chores themselves. You stop being the human middleware between their card and Stripe.
This is the operational order I'd use before you hit 50 — not because 50 is magic, just because after that the email volume makes the Dashboard click-path painful. Not legal advice. Grounded in Stripe's customer portal docs.
1. What to turn on first (minimal config)
Configure the portal in the Dashboard (or via portal Configurations in the API). Live mode and sandbox each have their own config — changing one does not change the other. Easy to forget when you tested everything in test mode and then wonder why live customers see different buttons.
For a tiny SaaS under ~50 customers, turn these on first:
| Feature | Start with | Why |
|---|---|---|
| Payment method update | On | Stops "my card expired" emails |
| Invoice history (view / pay / download) | On | Stops "send me last month's invoice" emails |
| Cancel | On, at period end | Stops you doing cancels by hand; keeps access until paid-through |
| Update billing info / tax IDs | On if you charge tax or need company details | Optional early |
| Subscription update (plan change / quantity) | Off until you have a real catalog + proration story | Plan upgrades without prices is a trap |
Leave plan upgrades off until you've set a product catalog the portal can actually show. Stripe requires that catalog if customers can upgrade, downgrade, or change quantities. Under 50 people, you can still change plans for them in the Dashboard when someone asks. Self-serve upgrades without clear prices just creates weird mid-cycle invoices you'll have to explain.
Cancel at period end is the default I'd pick at this size. Immediate cancel is fine for some products; just don't leave it on by accident because you clicked through the settings once.
Preview from the Dashboard: open a sandbox customer → Actions → Open customer portal. Read-only Preview exists while you tweak settings. Use it before you put a button in production.
2. One "Manage billing" button (not a permanent URL)
The integration is small:
- Customer is logged into your app (authenticate first — Stripe is clear on this).
- Your backend creates a Billing Portal Session with their Stripe
customerID and areturn_url. - Redirect them to
session.url.
Something like:
POST /create-portal-session
→ stripe.billingPortal.sessions.create({
customer: cus_xxx,
return_url: "https://yourapp.com/account"
})
→ redirect to session.url
Sessions are short-lived. Stripe's docs: a new portal session expires after about 5 minutes if unused; if the customer uses it, it expires within about 1 hour of the most recent activity. So don't paste a permanent portal URL into email and hope it works next week. Create a session when they click, then redirect.
There's also a shareable login_page link per configuration. Treat that as a special case, not your default. The normal path is: auth on your site → create session → redirect. Don't use the customer billing email as a login credential either — billing email updates in the portal are billing info, not account login.
3. Deep links for the three emails you already send
Once the homepage portal works, deep-link the emails you're already writing. Set flow_data[type] when you create the session. Navigational chrome for the rest of the portal is hidden so they land on one action.
| Email you already send | flow_data[type] |
Notes |
|---|---|---|
| Failed payment / expired card | payment_method_update |
Put this in dunning and "card expiring" mail |
| "I want to cancel" / cancel confirmation | subscription_cancel |
Pass the subscription ID; pair with period-end cancel |
| Tax ID / address / company name | customer_update |
Added as a portal deep link in Stripe's 2026-08-26 changelog |
Also available when you need them later: subscription_update, subscription_update_confirm. Skip those until plan changes are actually enabled in the config.
Top-level return_url is the "back to your site" link they can use anytime (e.g. they decide not to cancel). flow_data[after_completion] can redirect after they finish the action, show a hosted confirmation, or send them to the portal homepage. For cancel emails, a clear after_completion redirect back to your account page is less confusing than dumping them on a generic Stripe screen.
You don't need fancy copy. You need the link to open the right screen on the first click.
4. Webhooks so access matches what they did
The portal changes Stripe objects. Your app's entitlements have to follow. From Stripe's integrate guide, the ones that matter for a tiny setup:
| Event | Why you care |
|---|---|
customer.subscription.updated |
Plan change, quantity, or cancel scheduled at period end (cancel_at_period_end / cancel_at depending on billing mode) |
customer.subscription.deleted |
Subscription actually ended — revoke access here if you cancel at period end |
payment_method.attached / detached
|
Card changed; useful for support and for clearing "update card" banners |
customer.updated |
Billing details / default payment method changed — don't treat billing email as login |
The cancel trap: if the portal is set to cancel at period end, the first signal is usually customer.subscription.updated with cancel-at-period-end set, not deleted. If you revoke access on that update, you cut them off while they still paid through the period. Keep access until deleted (or until current_period_end / cancel_at actually passes — same idea). If they reactivate before the end, you'll get another updated with cancel cleared — restore "not leaving" state, don't invent a new subscription.
Same discipline as any other Stripe webhook: verify the signature on the raw body, store event.id for idempotency, re-fetch the subscription if you're unsure about ordering.
5. Traps that show up under 50 customers
Cancel left on "immediate" by accident. Someone cancels on day 3 of a monthly plan and loses access the same afternoon. If that's intentional, fine. If you meant period end, fix the config before the next person finds it.
Plan changes enabled with no (or wrong) catalog. Empty upgrade UI, or prices you never meant to sell. Keep subscription update off until the catalog is real.
Shareable login_page link as your only path. Convenient for a quick support paste. Worse for security hygiene than auth → session. Don't make it the product default without thinking.
Treating portal cancel as "access already gone" when cancel-at-period-end is true. See webhooks above. Your paywall and your email should agree: "you're paid through [date], then it ends."
Forgetting live-mode config is separate from test. You polished sandbox. Live still has defaults. Flip View test data off, configure live, add live webhooks, then ship the button.
Emailing a stale session URL. Five-minute unused expiry is short. Always create a fresh session from a button or a signed link on your domain.
If cancel UX is the part you're nervous about (retention copy, confirmation, what happens to data), that's a product design problem next to the portal config — how to design a cancel flow without dark patterns is the frame I'd use so the portal cancel screen and your in-app copy don't fight each other.
Portal also shows up in other small-scale ops — especially trial-ending mail that needs a card update before the first charge. If you're still wiring that flow, when a free trial ends under ~40 active trials is the sibling checklist. Source of truth for the portal itself: Stripe's customer portal overview and the API integrate guide.
If you're under 50 customers: what do you leave on vs off in the Customer Portal right now — and did you set cancel to period end or immediate on purpose?
Top comments (0)