Building a Self-Service Customer Portal with Stripe Billing
When a SaaS customer needs to update their payment method at 11 PM on a Sunday, they shouldn't have to wait until Monday morning to contact support. Yet many co...
Osmoto Team
Senior Software Engineer

When a SaaS customer needs to update their payment method at 11 PM on a Sunday, they shouldn't have to wait until Monday morning to contact support. Yet many companies still force customers through manual processes for basic subscription management tasks like updating billing information, downloading invoices, or changing plans. This friction doesn't just frustrate customers—it directly impacts your revenue through increased churn and support costs.
Stripe's Customer Portal provides a solution: a hosted, secure interface that handles common subscription management tasks without requiring custom development. However, implementing it effectively requires more than just dropping in a few lines of code. You need to consider user experience flows, handle edge cases, and ensure the portal integrates seamlessly with your application's authentication and billing logic.
In this guide, we'll walk through building a complete self-service customer portal implementation, covering everything from basic setup to advanced customization techniques. You'll learn how to handle authentication flows, customize the portal experience, and avoid common pitfalls that can lead to customer confusion or billing discrepancies.
Understanding Stripe's Customer Portal Architecture
Before diving into implementation, it's crucial to understand how Stripe's Customer Portal works. Unlike building a custom billing interface, the portal is a hosted solution that lives on Stripe's secure servers. Your application creates a portal session and redirects users to Stripe, where they can manage their subscription details.
The portal handles several key functions out of the box:
- Payment method updates with PCI-compliant card collection
- Invoice download and payment history
- Subscription cancellation and reactivation
- Plan changes (when properly configured)
- Billing address updates
Here's the basic flow:
// Create a portal session server-side const session = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: 'https://yourapp.com/dashboard', }); // Redirect user to the portal window.location.href = session.url;
However, this basic implementation misses several critical considerations that separate a functional portal from a great user experience.
Setting Up Portal Configuration
The Customer Portal's behavior is controlled by a configuration object that you create once and reference in your portal sessions. This configuration determines what features are available to customers and how they're presented.
Creating Your Portal Configuration
Start by creating a portal configuration that matches your business model:
const portalConfiguration = await stripe.billingPortal.configurations.create({ business_profile: { headline: 'Manage your subscription', privacy_policy_url: 'https://yourapp.com/privacy', terms_of_service_url: 'https://yourapp.com/terms', }, features: { payment_method_update: { enabled: true, }, invoice_history: { enabled: true, }, customer_update: { enabled: true, allowed_updates: ['email', 'address', 'shipping', 'phone', 'tax_id'], }, subscription_cancel: { enabled: true, mode: 'at_period_end', proration_behavior: 'none', cancellation_reason: { enabled: true, options: [ 'too_expensive', 'missing_features', 'switched_service', 'unused', 'other', ], }, }, subscription_update: { enabled: true, default_allowed_updates: ['price', 'quantity'], proration_behavior: 'create_prorations', products: [ { product: 'prod_basic_plan', prices: ['price_basic_monthly', 'price_basic_yearly'], }, { product: 'prod_pro_plan', prices: ['price_pro_monthly', 'price_pro_yearly'], }, ], }, }, });
This configuration enables key self-service features while maintaining control over the customer experience. The subscription_update section is particularly important—it defines which plans customers can switch between without contacting support.
Handling Multiple Business Models
If you have different customer segments (B2B vs B2C, different pricing tiers), you may need multiple portal configurations:
// B2B customers get more features const b2bPortalConfig = await stripe.billingPortal.configurations.create({ features: { subscription_update: { enabled: true, default_allowed_updates: ['price', 'quantity'], // B2B customers can change quantities }, invoice_history: { enabled: true, }, }, }); // B2C customers have simpler options const b2cPortalConfig = await stripe.billingPortal.configurations.create({ features: { subscription_update: { enabled: true, default_allowed_updates: ['price'], // B2C customers can only change plans, not quantities }, }, });
Store these configuration IDs in your environment variables and select the appropriate one based on customer type when creating portal sessions.
Implementing Secure Authentication Flow
The portal requires careful authentication handling since you're redirecting users to an external service. Here's a complete implementation that handles authentication, session creation, and error handling:
Server-Side Portal Session Creation
// pages/api/create-portal-session.ts import { NextApiRequest, NextApiResponse } from 'next'; import { getServerSession } from 'next-auth/next'; import { authOptions } from './auth/[...nextauth]'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16', }); export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method !== 'POST') { return res.status(405).json({ error: 'Method not allowed' }); } try { // Verify user authentication const session = await getServerSession(req, res, authOptions); if (!session?.user?.id) { return res.status(401).json({ error: 'Unauthorized' }); } // Get customer ID from your database const customer = await getUserStripeCustomer(session.user.id); if (!customer?.stripeCustomerId) { return res.status(400).json({ error: 'No subscription found' }); } // Verify customer has active subscription const subscriptions = await stripe.subscriptions.list({ customer: customer.stripeCustomerId, status: 'active', limit: 1, }); if (subscriptions.data.length === 0) { return res.status(400).json({ error: 'No active subscription found' }); } // Create portal session with appropriate configuration const portalSession = await stripe.billingPortal.sessions.create({ customer: customer.stripeCustomerId, return_url: `${process.env.NEXTAUTH_URL}/dashboard?tab=billing`, configuration: getPortalConfigForCustomer(customer), }); res.json({ url: portalSession.url }); } catch (error) { console.error('Portal session creation failed:', error); res.status(500).json({ error: 'Failed to create portal session' }); } } function getPortalConfigForCustomer(customer: any): string { // Return appropriate portal configuration based on customer type return customer.type === 'enterprise' ? process.env.STRIPE_PORTAL_CONFIG_B2B! : process.env.STRIPE_PORTAL_CONFIG_B2C!; }
Client-Side Portal Access
Create a reusable hook for accessing the portal:
// hooks/useCustomerPortal.ts import { useState } from 'react'; import { toast } from 'react-hot-toast'; export function useCustomerPortal() { const [isLoading, setIsLoading] = useState(false); const openPortal = async () => { setIsLoading(true); try { const response = await fetch('/api/create-portal-session', { method: 'POST', headers: { 'Content-Type': 'application/json', }, }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to open billing portal'); } // Redirect to Stripe portal window.location.href = data.url; } catch (error) { console.error('Portal access failed:', error); toast.error( error instanceof Error ? error.message : 'Unable to open billing portal' ); } finally { setIsLoading(false); } }; return { openPortal, isLoading }; }
Portal Access Component
// components/BillingPortalButton.tsx import { useCustomerPortal } from '../hooks/useCustomerPortal'; interface BillingPortalButtonProps { variant?: 'primary' | 'secondary'; children?: React.ReactNode; } export function BillingPortalButton({ variant = 'secondary', children = 'Manage Billing' }: BillingPortalButtonProps) { const { openPortal, isLoading } = useCustomerPortal(); return ( <button onClick={openPortal} disabled={isLoading} className={` px-4 py-2 rounded-md font-medium transition-colors ${variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-200 text-gray-800 hover:bg-gray-300' } ${isLoading ? 'opacity-50 cursor-not-allowed' : ''} `} > {isLoading ? 'Opening...' : children} </button> ); }
Customizing the Portal Experience
While Stripe's portal handles the heavy lifting, you can customize the experience to match your brand and business requirements.
Branding and Messaging
Configure your portal to match your application's look and feel:
const portalConfiguration = await stripe.billingPortal.configurations.create({ business_profile: { headline: 'Manage Your Subscription', privacy_policy_url: 'https://yourapp.com/privacy', terms_of_service_url: 'https://yourapp.com/terms', }, default_return_url: 'https://yourapp.com/dashboard', // Add custom CSS for advanced branding (Stripe Connect accounts only) login_page: { enabled: false, // Use your own authentication }, });
Controlling Available Actions
Fine-tune what customers can do in the portal based on their subscription status or customer segment:
// For customers on annual plans, disable downgrades during first 6 months async function createPortalSessionWithRestrictions( customerId: string, subscriptionData: any ) { const isNewAnnualCustomer = subscriptionData.billing_cycle_anchor > Date.now() - (6 * 30 * 24 * 60 * 60 * 1000); const configuration = await stripe.billingPortal.configurations.create({ features: { subscription_update: { enabled: !isNewAnnualCustomer, default_allowed_updates: isNewAnnualCustomer ? [] : ['price'], }, subscription_cancel: { enabled: true, mode: 'at_period_end', // Require cancellation reason for annual customers cancellation_reason: { enabled: isNewAnnualCustomer, options: ['too_expensive', 'missing_features', 'other'], }, }, }, }); return await stripe.billingPortal.sessions.create({ customer: customerId, return_url: 'https://yourapp.com/dashboard', configuration: configuration.id, }); }
Dynamic Return URLs
Customize where users land after portal actions based on what they did:
const portalSession = await stripe.billingPortal.sessions.create({ customer: customerId, return_url: `${baseUrl}/dashboard?tab=billing&action=portal_return×tamp=${Date.now()}`, });
Then handle the return in your application:
// pages/dashboard.tsx import { useRouter } from 'next/router'; import { useEffect } from 'react'; export default function Dashboard() { const router = useRouter(); useEffect(() => { if (router.query.action === 'portal_return') { // Show success message or refresh billing data toast.success('Billing settings updated successfully'); // Refresh subscription data mutate('/api/subscription'); } }, [router.query]); // ... rest of component }
Handling Webhook Events for Portal Actions
When customers make changes in the portal, Stripe sends webhooks to keep your application in sync. Here's how to handle the most important portal-related events:
Setting Up Portal Webhook Handler
// pages/api/webhooks/stripe.ts import { NextApiRequest, NextApiResponse } from 'next'; import { buffer } from 'micro'; import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; export const config = { api: { bodyParser: false, }, }; export default async function handler( req: NextApiRequest, res: NextApiResponse ) { if (req.method !== 'POST') { return res.status(405).end(); } const buf = await buffer(req); const signature = req.headers['stripe-signature'] as string; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(buf, signature, webhookSecret); } catch (err) { console.error('Webhook signature verification failed:', err); return res.status(400).send('Webhook signature verification failed'); } try { switch (event.type) { case 'customer.subscription.updated': await handleSubscriptionUpdate(event.data.object as Stripe.Subscription); break; case 'customer.subscription.deleted': await handleSubscriptionCancellation(event.data.object as Stripe.Subscription); break; case 'invoice.payment_method_changed': await handlePaymentMethodChange(event.data.object as Stripe.Invoice); break; case 'customer.updated': await handleCustomerUpdate(event.data.object as Stripe.Customer); break; default: console.log(`Unhandled event type: ${event.type}`); } res.status(200).json({ received: true }); } catch (error) { console.error('Webhook handling failed:', error); res.status(500).json({ error: 'Webhook handling failed' }); } } async function handleSubscriptionUpdate(subscription: Stripe.Subscription) { // Update subscription data in your database await updateSubscriptionInDatabase(subscription.id, { status: subscription.status, current_period_end: new Date(subscription.current_period_end * 1000), plan_id: subscription.items.data[0]?.price.id, quantity: subscription.items.data[0]?.quantity, }); // Send confirmation email for plan changes if (subscription.metadata?.plan_changed === 'true') { await sendPlanChangeConfirmation(subscription.customer as string); } } async function handleSubscriptionCancellation(subscription: Stripe.Subscription) { // Mark subscription as cancelled but keep access until period end await updateSubscriptionInDatabase(subscription.id, { status: 'cancelled', cancel_at_period_end: true, cancelled_at: new Date(), }); // Trigger cancellation email sequence await triggerCancellationWorkflow(subscription.customer as string); }
Syncing Customer Data Changes
When customers update their information in the portal, ensure your application stays synchronized:
async function handleCustomerUpdate(customer: Stripe.Customer) { const updates: any = {}; // Update email if changed if (customer.email) { updates.email = customer.email; } // Update billing address if (customer.address) { updates.billing_address = { line1: customer.address.line1, line2: customer.address.line2, city: customer.address.city, state: customer.address.state, postal_code: customer.address.postal_code, country: customer.address.country, }; } // Update tax ID if (customer.tax_ids) { updates.tax_id = customer.tax_ids.data[0]?.value; } await updateCustomerInDatabase(customer.id, updates); }
Common Pitfalls and Edge Cases
Portal Access Without Active Subscription
One common issue is customers trying to access the portal after their subscription has been cancelled or expired:
// Enhanced portal session creation with subscription validation async function createPortalSession(customerId: string) { // Check for any subscription (active, past_due, or cancelled) const subscriptions = await stripe.subscriptions.list({ customer: customerId, limit: 1, }); if (subscriptions.data.length === 0) { throw new Error('No subscription history found'); } const subscription = subscriptions.data[0]; // Handle different subscription states if (subscription.status === 'incomplete') { // Redirect to payment completion instead of portal return { redirectTo: `/complete-payment?subscription=${subscription.id}` }; } if (subscription.status === 'cancelled' && subscription.current_period_end < Date.now() / 1000) { // Subscription fully expired - redirect to reactivation flow return { redirectTo: '/reactivate-subscription' }; } // Create portal session for other states return await stripe.billingPortal.sessions.create({ customer: customerId, return_url: 'https://yourapp.com/dashboard', }); }
Handling Plan Changes with Usage-Based Billing
If you use usage-based billing or metered components, portal plan changes can create billing complications:
// Configure portal to handle metered billing properly const portalConfig = await stripe.billingPortal.configurations.create({ features: { subscription_update: { enabled: true, default_allowed_updates: ['price'], proration_behavior: 'create_prorations', // Restrict plan changes for usage-based subscriptions products: [ { product: 'prod_basic', prices: ['price_basic_monthly'], // Only allow non-metered plans }, ], }, }, });
Customer Confusion with Multiple Subscriptions
Some customers may have multiple subscriptions (e.g., different products or add-ons). The portal can become confusing in these scenarios:
// Check for multiple subscriptions and provide guidance async function validatePortalAccess(customerId: string) { const subscriptions = await stripe.subscriptions.list({ customer: customerId, status: 'active', }); if (subscriptions.data.length > 1) { // Create portal with limited features for complex billing scenarios return await stripe.billingPortal.sessions.create({ customer: customerId, configuration: process.env.STRIPE_PORTAL_CONFIG_SIMPLE!, // Payment methods only return_url: 'https://yourapp.com/dashboard?multiple_subscriptions=true', }); } // Standard portal for single subscription customers return await stripe.billingPortal.sessions.create({ customer: customerId, configuration: process.env.STRIPE_PORTAL_CONFIG_FULL!, return_url: 'https://yourapp.com/dashboard', }); }
Best Practices Summary
Based on implementing customer portals across dozens of SaaS applications, here are the essential best practices:
Authentication and Security:
- Always verify user authentication before creating portal sessions
- Validate that the user owns the Stripe customer ID
- Use HTTPS for all portal-related endpoints
- Implement proper CSRF protection on portal creation endpoints
User Experience:
- Provide clear context about what the portal does before redirecting
- Handle loading states during portal session creation
- Show appropriate error messages for common failure scenarios
- Use meaningful return URLs that indicate successful portal actions
Configuration Management:
- Create different portal configurations for different customer segments
- Regularly review and update portal configurations as your product evolves
- Test portal configurations in Stripe's test mode before deploying
- Document which features are enabled for different customer types
Webhook Handling:
- Process all relevant webhook events to keep your database synchronized
- Implement idempotency checks for webhook handlers
- Add proper error handling and retry logic for webhook processing
- Monitor webhook delivery success rates in Stripe's dashboard
Error Handling:
- Gracefully handle customers without active subscriptions
- Provide alternative flows for edge cases (expired subscriptions, payment failures)
- Log portal access attempts for debugging and analytics
- Implement fallback options when portal access fails
Conclusion
A well-implemented customer portal significantly reduces support burden while improving customer satisfaction. By leveraging Stripe's hosted solution, you get enterprise-grade security and compliance without the development overhead of building custom billing interfaces.
The key to success lies in thoughtful configuration, proper authentication flows, and handling edge cases gracefully. Remember that the portal is often a customer's first interaction with your billing system outside of the initial signup—making it smooth and intuitive directly impacts retention and satisfaction.
For SaaS businesses looking to implement comprehensive subscription management, including customer portals, prorated upgrades, and dunning management, our Stripe Subscriptions service provides end-to-end implementation with all the best practices covered in this guide. We handle the complex edge cases and integration challenges so you can focus on growing your business.
Consider implementing the portal as part of a broader self-service strategy that includes clear pricing communication, transparent billing policies, and proactive customer communication about subscription changes. When customers can easily manage their own billing, everyone wins.
Related Articles

