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