"We just got our first paid subscriber and they asked how to downgrade. We don't have a settings page." That question hits every SaaS developer two weeks after the checkout flow goes live.
The good news: Stripe's Customer Portal handles plan upgrades, downgrades, payment method updates, and cancellations in a hosted UI. You don't write a single line of frontend code for the settings page. You create a session, redirect the customer, and Stripe handles the rest.
The harder part: the portal fires different webhooks than your checkout flow. And there's one cancellation behavior that will silently break your access revocation logic if you don't know about it.
TL;DR: Enable the portal in 2 minutes (Stripe Dashboard > Settings > Billing > Customer portal). Subscription changes fire customer.subscription.updated, same handler you already have. Cancellations fire customer.subscription.updated with cancel_at_period_end: true, NOT customer.subscription.deleted. subscription.deleted fires at period end, sometimes 29 days later. Plan for that.
Key takeaways
- billing_portal.session.created fires when the portal opens, not when the customer acts on anything
- Plan changes from the portal fire customer.subscription.updated (same handler you already wrote)
- Cancellations set
cancel_at_period_end: trueon a subscription update.subscription.deleteddoes NOT fire immediately - Enable the portal in 2 minutes: Stripe Dashboard > Settings > Billing > Customer portal
- Test locally with
stripe listenand a real portal session URL from the API
What the Stripe Customer Portal Actually Handles
The portal is a Stripe-hosted settings page your subscribers open via a short-lived URL you generate per customer. They see their current plan, payment method, billing history, and options you configure.
With default settings, they can:
- Update their payment method
- View and download invoices
- Upgrade or downgrade their subscription (to plans you specify)
- Cancel their subscription
What it does NOT handle:
- Trial extensions
- Mid-cycle pauses without a subscription schedule
- Branded emails (Stripe sends from no-reply@stripe.com)
- Custom cancellation flows with exit surveys
For 90% of SaaS products, the portal is enough. Build the custom settings page after your first 100 customers, not before.
Enabling the Portal: 2 Minutes in the Dashboard
Go to Stripe Dashboard > Settings > Billing > Customer portal.
Check these boxes:
- Allow customers to update their payment method
- Allow customers to view their billing history
- Allow customers to cancel their subscription
Two things to configure that Stripe leaves unchecked by default:
Set cancellation to "at end of billing period." This prevents Stripe from issuing a proration refund the moment someone cancels. They keep access until their paid period ends, then they lose it. Cleaner for you, and what most customers expect.
Enable plan switching. If you want customers to self-serve upgrades and downgrades, go to the "Subscriptions" section and enable "Allow customers to switch to a different plan." Add the specific plan IDs they can switch between. This takes 30 seconds and means you never have to manually process a plan change email again.
If you need to configure the portal programmatically (useful for multi-tenant setups where each customer gets a different configuration), here's the API call:
import stripe
stripe.api_key = "sk_live_..."
config = stripe.billing_portal.Configuration.create(
business_profile={
"headline": "Manage your subscription",
"privacy_policy_url": "https://yourdomain.com/privacy/",
"terms_of_service_url": "https://yourdomain.com/terms/",
},
features={
"payment_method_update": {"enabled": True},
"invoice_history": {"enabled": True},
"subscription_cancel": {
"enabled": True,
"mode": "at_period_end", # or "immediately" if you want instant cancellations
"proration_behavior": "none",
},
"subscription_update": {
"default_allowed_updates": ["price"],
"enabled": True,
"products": [
{
"product": "prod_xxx",
"prices": ["price_monthly_id", "price_annual_id"],
}
],
},
},
)
print(config.id) # save this as your default configuration ID
Once configured, generate a portal session per customer. You'll need the Stripe customer_id, which you captured when they first subscribed (it's in the checkout.session.completed event payload).
from fastapi import FastAPI, Request
@app.post("/create-portal-session")
async def create_portal_session(customer_id: str, return_url: str):
session = stripe.billing_portal.Session.create(
customer=customer_id,
return_url=return_url or "https://yourdomain.com/settings",
)
return {"url": session.url} # redirect the customer to this URL
The session URL expires after 5 minutes if the customer doesn't open it. Generate it on-demand when they click "Manage subscription" in your app. Don't cache it.
The Event You'll Ignore (And One Reason Not To)
billing_portal.session.created fires the moment a customer opens the portal URL. It doesn't tell you they changed anything. It just tells you they were in there.
Most teams ignore it at first. Fine. But here's when it becomes useful: if a customer opens the portal three times in four days without making any changes, that's a churn signal. They're probably trying to cancel and getting cold feet, or confused about their options. A proactive "anything I can help with?" email at that point recovers a surprising number of customers.
The event payload is simple:
{
"type": "billing_portal.session.created",
"data": {
"object": {
"id": "bps_1OkPz...",
"customer": "cus_xxx",
"created": 1718524800,
"return_url": "https://yourdomain.com/settings",
"url": "https://billing.stripe.com/session/..."
}
}
}
For now, log it and move on. Add the retention hook after you have 50+ customers and a churn rate worth worrying about.
How Plan Changes Actually Fire
When a customer upgrades or downgrades from the portal, Stripe fires customer.subscription.updated. This is the same event you already handle from your subscription lifecycle webhook handler.
The difference is in the payload. Check items.data[0].price.id to see which plan they switched to. If you're storing the active plan in your database, update it here:
if event["type"] == "customer.subscription.updated":
sub = event["data"]["object"]
new_price_id = sub["items"]["data"][0]["price"]["id"]
customer_id = sub["customer"]
# Map the Stripe price ID to your internal plan tier
plan_tier = PRICE_TO_PLAN_MAP.get(new_price_id, "unknown")
await db.execute(
"UPDATE subscriptions SET plan_tier = $1, stripe_price_id = $2, updated_at = NOW() "
"WHERE stripe_customer_id = $3",
plan_tier, new_price_id, customer_id
)
# Check if it's a downgrade and trigger a retention email
previous_attributes = event["data"].get("previous_attributes", {})
if "items" in previous_attributes:
old_price_id = previous_attributes["items"]["data"][0]["price"]["id"]
if is_downgrade(old_price_id, new_price_id):
await send_downgrade_email(customer_id)
previous_attributes is the key field. It only includes fields that changed. If items is in previous_attributes, the plan changed. If cancel_at_period_end is in previous_attributes, someone just requested a cancellation or reverted one.
The Cancellation Gotcha: Why subscription.deleted Doesn't Fire When You Expect
Here's the part that trips everyone up the first time.
When a customer clicks "cancel" in the portal and you've configured cancellation at period end, Stripe does NOT fire customer.subscription.deleted.
It fires customer.subscription.updated with cancel_at_period_end: true.
customer.subscription.deleted fires when the subscription actually ends, at the end of the billing period. If they cancel on day 2 of a 30-day billing cycle, you won't see subscription.deleted for 28 days.
subscription.deleted as your only "cancel" signal, your cancellation confirmation email fires 28 days late. Your access revocation logic is correct (access ends at period end), but the customer gets no confirmation that their cancellation was processed. Support tickets follow.
The right pattern is to handle both events separately:
if event["type"] == "customer.subscription.updated":
sub = event["data"]["object"]
previous = event["data"].get("previous_attributes", {})
# Cancellation just requested (cancel_at_period_end flipped to True)
if (sub.get("cancel_at_period_end") and
not previous.get("cancel_at_period_end")):
cancel_at = sub["cancel_at"] # Unix timestamp of period end
customer_id = sub["customer"]
# Send confirmation email NOW (customer expects this immediately)
await send_cancellation_confirmation_email(
customer_id=customer_id,
access_until=cancel_at
)
# Schedule access revoke for the period end date
await schedule_access_revoke(
customer_id=customer_id,
revoke_at=cancel_at
)
# Customer changed their mind and un-cancelled
if (not sub.get("cancel_at_period_end") and
previous.get("cancel_at_period_end")):
await cancel_scheduled_revoke(sub["customer"])
await send_reactivation_confirmation_email(sub["customer"])
if event["type"] == "customer.subscription.deleted":
# This fires when access actually ends (at period end, or if you cancel immediately via API)
customer_id = event["data"]["object"]["customer"]
await revoke_access(customer_id)
await send_post_churn_winback_email(customer_id, delay_days=3)
Four distinct states, four distinct actions. The cancellation confirmation fires immediately on the subscription.updated event. The access revoke fires at period end via subscription.deleted. The winback email fires 3 days after subscription.deleted via a scheduled job.
Comparison: Portal Webhook Behavior vs Direct API Cancellation
| Action | Via Customer Portal | Via Stripe API (stripe.Subscription.delete) |
|---|---|---|
| Plan upgrade | customer.subscription.updated | customer.subscription.updated |
| Plan downgrade | customer.subscription.updated | customer.subscription.updated |
| Cancel (at period end) | customer.subscription.updated (cancel_at_period_end: true) | customer.subscription.updated (cancel_at_period_end: true) |
| Cancel (immediate) | customer.subscription.deleted (if configured) | customer.subscription.deleted |
| Period end (after cancel) | customer.subscription.deleted | customer.subscription.deleted |
| Portal opened | billing_portal.session.created | n/a |
Look at the third row. "Cancel at period end" fires the same event whether it comes from the portal or the API. Your handler doesn't need to distinguish the source. It just needs to check cancel_at_period_end.
Testing the Full Portal Flow Locally
You need three things: a running local server, the Stripe CLI forwarding webhooks, and a real portal session URL pointed at a test customer.
Open three terminal tabs:
# Tab 1: your server
uvicorn main:app --reload --port 8000
# Tab 2: Stripe CLI (copy the webhook secret it prints)
stripe listen --forward-to localhost:8000/webhooks
# Tab 3: generate a portal session for your test customer
stripe customers list --limit=1 # grab a test customer ID
python -c "
import stripe
stripe.api_key = 'sk_test_...'
s = stripe.billing_portal.Session.create(
customer='cus_test_xxx',
return_url='http://localhost:3000/settings'
)
print(s.url)
"
Open the session URL in your browser. Make a change (downgrade the plan, click cancel). Watch your webhook logs in Tab 1. You'll see the exact event payload that fires in production.
One thing to know: the portal URL is tied to the configured portal settings in your Stripe account. If you haven't configured plan switching in the dashboard, you won't see the plan options in the test portal UI. Configure first, test second.
What Comes Next
The portal plus the 4 subscription lifecycle webhooks give you a complete billing layer. Checkout, fulfillment, plan changes, cancellations, access revocation. No custom billing UI needed until you have customers asking for features the portal doesn't support.
The next piece is trial periods. How to gate features during trial, which 3 events fire when a trial ends, and what to do when a trial ends without a payment method on file. That's the next post in this series.