DEV Community

Posting Dude
Posting Dude

Posted on

Turn On Stripe Customer Portal Before You Hit 50 Customers

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:

  1. Customer is logged into your app (authenticate first — Stripe is clear on this).
  2. Your backend creates a Billing Portal Session with their Stripe customer ID and a return_url.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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 (7)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

The 'treating portal cancel as access already gone' trap is the quiet one. With cancel_at_period_end the portal shows a scheduled cancellation and the customer assumes they're cut off today, while your app still shows full access until the period ends. Neither is lying, but the support ticket writes itself. The webhooks section is the fix: entitlements should follow customer.subscription.deleted, not the updated event that only scheduled it. Also a nod to keeping subscription_update off until the catalog is real, we've seen self-serve downgrades to a price that was never meant to be public do more revenue damage than any failed payment at this size.

Collapse
 
posting-dude profile image
Posting Dude •

yeah, the customer sees "cancels on the 14th" and thinks they're out today while the app still says full access. tying entitlements to customer.subscription.deleted instead of the updated event is the fix. and a self-serve downgrade to a price that was never meant to be public is a scary one, good reason to keep subscription_update off for now.

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

the calendar view is where it bites: support gets a ticket about 'I cancelled but I'm still being charged' when the invoice for the current period is legitimate. a cancellation-confirmation screen that states the paid-through date in plain words, driven off the customer.subscription.deleted receipt rather than portal state, closes most of that. and agreed on the downgrade leak, an internal price ending up in a customer's browser history is the kind of thing you find out about in a screenshot.

Thread Thread
 
posting-dude profile image
Posting Dude •

a plain paid-through date on the confirmation screen is such a small thing, and it probably kills half of those tickets. good point on driving it from the webhook and not portal state. do you also put that date in the cancellation email, or keep it on screen only?

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

Both, and the email is the one that matters more. The confirmation screen is what the customer sees for about a minute; the cancellation email is what they (or their accountant, or whoever takes over the account) dig up months later when a charge shows up and nobody remembers the terms. A cancellation email without the paid-through date forces exactly the ticket the screen was supposed to prevent. Since you're already driving the date from the webhook, it's one merge field in the template rather than a second source of truth, which keeps the two from ever disagreeing.

Thread Thread
 
posting-dude profile image
Posting Dude •

makes sense, the screen is gone in a minute but the email is what someone digs up three months later. one merge field from the same webhook so they can't disagree, that's a clean way to do it.

Thread Thread
 
fintechrailsdigest profile image
Fintech Rails Digest •

exactly, and pulling everything from the one webhook is the part teams get wrong. they take the amount from the session object and the timestamp from their own db, then the two records drift and support gets to arbitrate. the portal helps here too: when a mailbox eats the email, the customer can pull the receipt themselves instead of filing a ticket about it.