Billing
Subscription billing system with Stripe integration, plans, checkout, and webhooks.
By the end of this guide, you'll understand how to implement a complete subscription billing system using Stripe integration. You'll learn to configure payment providers, manage subscription plans, handle checkout sessions, process webhooks, and integrate billing data into user sessions for a seamless SaaS experience.
Overview
The SaaS Boilerplate includes a comprehensive billing system built around Stripe's payment infrastructure. It provides:
- Multi-tenant subscriptions: Organization-scoped billing with role-based access
- Flexible pricing: Support for multiple plans, billing cycles, and currencies
- Usage tracking: Real-time quota monitoring and feature limits
- Self-service portal: Customer portal for subscription management
- Webhook processing: Automated handling of payment events and subscription changes
- Trial management: Configurable trial periods with automatic conversion
- Optional payment provider: Run without Stripe for non-paid SaaS applications
The system integrates deeply with authentication, automatically including billing information in user sessions for seamless access control and feature gating.
Optional Payment Provider
The billing system supports running without a payment provider, making it perfect for non-paid SaaS applications, MVPs, or development environments.
When Payment is Disabled
When Stripe environment variables are not configured, the application automatically:
- Hides billing UI components: Pricing pages, upgrade buttons, and billing settings are hidden or show placeholders
- Grants full access: All users have unrestricted access without subscription requirements
- Skips payment operations: Billing APIs return appropriate errors or null values
- Maintains functionality: Core features work normally without payment dependencies
Configuration
Enable Payment Provider
To enable Stripe integration, set all required environment variables:
# Required for payment functionality
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Disable Payment Provider
To run without payment features, simply leave the Stripe variables empty or remove them:
# Leave empty to disable payment
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=Migration Between Modes
From No Payment to Payment Enabled
- Obtain Stripe API keys from your Stripe dashboard
- Add the environment variables to your
.envfile - Restart the application
- The system will automatically sync plans and enable billing features
- Existing organizations may need manual customer creation in Stripe
From Payment Enabled to No Payment
- Remove or clear the Stripe environment variables
- Restart the application
- Billing features are automatically hidden
- All users gain unrestricted access
- Existing billing data remains in the database but is not accessed
Development Benefits
Running without payment provider is ideal for:
- Rapid prototyping: Build and test features without payment setup
- MVP development: Launch without monetization initially
- Team collaboration: Developers can work without Stripe accounts
- CI/CD testing: Automated tests run without payment dependencies
Technical Implementation
The system automatically detects payment provider status using a client-side utility:
// Frontend detection
import { isPaymentEnabled } from '@/@saas-boilerplate/features/billing/presentation/utils/is-payment-enabled'
if (!isPaymentEnabled()) {
// Hide billing components
return null
}Backend procedures check payment status before executing billing operations:
// Backend validation
if (!isPaymentEnabled()) {
// Return null or appropriate error
return null
}Architecture
Payment Provider
The core of the billing system is the PaymentProvider class, which orchestrates all payment operations:
// src/@saas-boilerplate/providers/payment/payment.provider.ts
class PaymentProvider<TPlans extends PlanDTO[]> {
private adapter: StripeAdapter
private database: PrismaAdapter
private events: PaymentEvents
// Core methods for subscription lifecycle
async createSubscription(params: CreateSubscriptionParams)
async updateSubscription(params: UpdateSubscriptionParams)
async cancelSubscription(subscriptionId: string)
async createCheckoutSession(params: CheckoutSessionParams)
async createBillingPortal(customerId: string)
async handle(request: Request) // Webhook processing
}Service Configuration
The payment service is initialized with Stripe credentials and configuration:
// src/services/payment.ts
export const payment = PaymentProvider.initialize({
database: prismaAdapter(prisma),
adapter: stripeAdapter({
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
}),
paths: {
checkoutCancelUrl: `${baseUrl}/billing/cancel`,
checkoutSuccessUrl: `${baseUrl}/billing/success`,
portalReturnUrl: `${baseUrl}/settings/billing`,
},
subscriptions: {
enabled: true,
trial: { enabled: true, duration: 14 },
plans: {
default: 'free',
options: [
{ slug: 'free', name: 'Free', prices: [...] },
{ slug: 'pro', name: 'Pro', prices: [...] },
]
}
}
})Context Integration
The payment service is automatically available in the Igniter context, so you can use it directly in procedures and controllers without importing:
// Available in any procedure or controller
const canUse = await context.services.payment.canUseFeature({
customerId: organizationId,
feature: 'api_calls',
quantity: 1
})Setting Up Billing
Setting Up Billing
Configure Stripe (Optional)
The payment provider is completely optional. For non-paid applications, you can skip this step entirely.
To enable Stripe integration, set up your Stripe account and obtain API keys:
# Add to .env (leave empty to disable payment features)
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Create products and prices in your Stripe dashboard, then map them in your configuration.
Note: If you leave these variables empty, the application will run with all billing features disabled, granting full access to all users.
Initialize Payment Service
Configure the payment provider with your Stripe credentials and plan definitions:
// src/services/payment.ts
const { keys, paths, subscription } = AppConfig.providers.billing
export const payment = PaymentProvider.initialize({
database: prismaAdapter(prisma),
adapter: stripeAdapter(keys),
paths,
subscriptions: {
enabled: subscription.enabled,
trial: subscription.trial,
plans: subscription.plans,
},
})Set Up Webhooks
Configure Stripe webhooks to sync subscription changes:
# Start local webhook forwarding
npm run stripe:webhook
# Copy the webhook secret to .env
STRIPE_WEBHOOK_SECRET=whsec_...The webhook endpoint automatically handles events like customer.created, invoice.paid, and customer.subscription.updated.
Integrate with Authentication
Billing data is automatically included in user sessions:
// Session includes billing information
const session = await context.auth.getSession({
requirements: 'authenticated',
roles: ['admin', 'owner']
})
// Access billing data
const { customer, subscription } = session.organization.billingSubscription Plans
Plan Structure
Plans are defined with features, pricing, and limits:
interface Plan {
slug: string
name: string
description: string
metadata: {
features: Array<{
slug: string
name: string
enabled: boolean
limit?: number
cycle?: 'month' | 'year'
}>
}
prices: Array<{
amount: number
currency: string
interval: 'month' | 'year'
}>
}Managing Plans
Use the Plan controller to retrieve available plans:
// Get all subscription plans
const plans = await api.plan.findMany.query()
// Each plan includes pricing and features
plans.forEach(plan => {
console.log(`${plan.name}: $${plan.price.amount}/${plan.price.interval}`)
})Backend Usage (Procedures & Controllers)
Feature Gating
Use billing data to control feature access in your backend logic. When payment is disabled, all users have unrestricted access:
// In a procedure or controller
const session = await context.auth.getSession({
requirements: 'authenticated'
})
// When payment is disabled, billing is null and users have full access
if (!session.organization?.billing?.subscription) {
// Payment disabled: allow full access
// Payment enabled: check subscription requirements
if (isPaymentEnabled()) {
throw new Error('Subscription required')
}
}
// Check feature limits (only when payment is enabled)
if (isPaymentEnabled()) {
const canUseApi = await context.services.payment.canUseFeature({
customerId: session.organization.id,
feature: 'api_calls',
quantity: 1
})
if (!canUseApi) {
throw new Error('API limit exceeded')
}
}Subscription Management
Handle subscription operations in your backend:
// Check quota before allowing action
const usage = await context.services.payment.getQuotaInfo({
customerId: organizationId,
feature: 'storage'
})
if (usage.usage >= usage.limit) {
throw new Error('Storage limit exceeded')
}
// Process subscription changes
const subscription = await context.services.payment.updateSubscription({
subscriptionId: 'sub_123',
planId: 'pro_plan'
})Frontend Usage (Client-side)
Billing components automatically hide when payment is disabled. Use the isPaymentEnabled() utility to conditionally render billing features:
Creating Checkout Sessions
Generate secure payment links for subscriptions (only when payment is enabled):
// Only show checkout when payment is enabled
if (!isPaymentEnabled()) {
return <div>Payment features are not available</div>
}
// Create checkout session for new subscription
const checkout = await api.billing.createCheckoutSession.mutate({
plan: 'pro',
cycle: 'month'
})
// Redirect to Stripe checkout
window.location.href = checkout.data.urlCustomer Portal
Allow customers to manage their own subscriptions:
// Create portal session
const portal = await api.billing.createSessionManager.mutate({
returnUrl: window.location.href
})
// Redirect to customer portal
window.location.href = portal.data.urlBilling Information
Retrieve complete billing information for organizations:
// Get full billing details
const billing = await api.billing.getSessionCustomer.query()
// Access all billing data
const {
customer,
subscription,
paymentMethods,
invoices
} = billing.dataBilling Information
Comprehensive Billing Data
Retrieve complete billing information for organizations:
// Get full billing details
const billing = await api.billing.getSessionCustomer.query()
// Access all billing data
const {
customer,
subscription,
paymentMethods,
invoices
} = billing.dataBilling Info Structure
Prop
Type
Webhooks & Events
Webhook Processing
The webhook endpoint automatically processes Stripe events:
// src/app/(api)/api/billing/webhook/route.ts
export const POST = async (request: Request) => {
const event = await payment.handle(request)
return new Response(JSON.stringify(event), { status: 200 })
}Supported Events
Usage Tracking & Quotas
Feature Limits
Track usage against plan limits:
// Check if user can use a feature (Backend)
const canUse = await context.services.payment.canUseFeature({
customerId: organizationId,
feature: 'api_calls',
quantity: 1
})
// Get current usage info (Backend)
const usage = await context.services.payment.getQuotaInfo({
customerId: organizationId,
feature: 'api_calls'
})Usage Structure
Prop
Type
Session Integration
Billing in Auth Sessions
Billing data is automatically included in authenticated sessions:
// Get session with billing data
const session = await context.auth.getSession({
requirements: 'authenticated',
roles: ['admin', 'owner']
})
// Access billing information
if (session.organization?.billing) {
const { customer, subscription } = session.organization.billing
// Check subscription status
if (subscription?.status === 'active') {
// User has active subscription
}
}Session Billing Types
Prop
Type
Practical Examples
Backend: Feature Gating
Use billing data to control feature access in your backend logic:
// In a procedure or controller
const session = await context.auth.getSession({
requirements: 'authenticated'
})
if (!session.organization?.billing?.subscription) {
throw new Error('Subscription required')
}
// Check feature limits
const canUseApi = await context.services.payment.canUseFeature({
customerId: session.organization.id,
feature: 'api_calls',
quantity: 1
})
if (!canUseApi) {
throw new Error('API limit exceeded')
}Frontend: Subscription Management UI
Build a billing dashboard:
// Get billing data for dashboard
const billing = await api.billing.getSessionCustomer.query()
// Display subscription info
const { subscription, customer, paymentMethods } = billing.data
// Show current plan
console.log(`Plan: ${subscription.plan.name}`)
console.log(`Status: ${subscription.status}`)
// Show usage
subscription.usage.forEach(feature => {
console.log(`${feature.name}: ${feature.usage}/${feature.limit}`)
})Backend: Webhook Event Handling
Extend webhook processing for custom logic:
// In webhook handler
const event = await payment.handle(request)
// Custom processing based on event type
switch (event.type) {
case 'customer.subscription.updated':
// Handle subscription changes
await handleSubscriptionUpdate(event.data)
break
case 'invoice.payment_failed':
// Handle failed payments
await handlePaymentFailure(event.data)
break
}Testing
Test Cards
Use Stripe test card numbers for development:
4242 4242 4242 4242 # Success
4000 0000 0000 0002 # Declined
4000 0025 0000 3155 # Insufficient fundsWebhook Testing
Test webhooks locally:
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/billing/webhook
# Trigger test events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_succeededTroubleshooting
Best Practices
See Also
- Setup Stripe - Complete Stripe configuration guide
- Authentication & Sessions - How billing integrates with auth
- Organizations and Tenancy - Multi-tenant billing architecture
- Jobs & Queues - Background processing for billing operations
- API Keys - Programmatic access to billing endpoints
API Reference
Billing Endpoints
Prop
Type
Plan Endpoints
Prop
Type
Webhook Events
Prop
Type