Stripe Subscription Setup with Supabase
Complete Guide: Stripe Subscription System with Supabase
Ever wondered how to build a bulletproof subscription system that handles everything from basic monthly/yearly billing to complex scenarios like plan switching and graceful cancellations? You're in the right place.
What We're Building
In this comprehensive guide, we'll create a production-ready subscription system that:
- Supports flexible billing: Monthly and yearly subscription plans
- Handles complex scenarios: Plan switching, cancellations, and resumptions
- Stays in sync: Real-time webhook integration between Stripe and Supabase
- Provides great UX: Graceful handling of edge cases and user flows
- Is production-ready: Includes error handling, security, and best practices
Prerequisites
Before we dive in, make sure you have:
- A Stripe account (test mode is fine for development)
- A Supabase project with database access
- Basic knowledge of TypeScript and Edge Functions
- Understanding of webhook concepts
System Architecture Overview
Our subscription system follows this flow:
User Interface → Edge Functions → Stripe API → Webhooks → Supabase Database
↑ ↓
←←←←←←←←← Real-time subscription state ←←←←←←←←←←←←←←←←←←←←←
Key Components:
- Stripe Dashboard: Products, prices, and webhook configuration
- Supabase Database: Subscription state and user data
- Edge Functions: Secure API endpoints for subscription operations
- Webhooks: Real-time sync between Stripe and your database
Phase 1: Setting Up Your Stripe Foundation
Let's start by configuring Stripe with everything we need for a robust subscription system.
Create Your Subscription Products
What we're doing: Setting up the foundation of your subscription offering in Stripe.
Create Your Product
- Navigate to Stripe Dashboard → Products
- Click "+ Add product"
- Fill in your product details:
- Name:
Premium Subscription(or your product name) - Description: A brief description of what the subscription includes
- Name:
Add Pricing Tiers
Now add two recurring prices to your product:
Monthly Plan:
- Billing period:
Monthly - Price:
£13.99(adjust to your pricing) - Billing interval:
Every 1 month
Yearly Plan:
- Billing period:
Yearly - Price:
£129.00(typically 10-20% discount) - Billing interval:
Every 1 year
Important: Save Your Price IDs
After creating each price, copy the Price ID (starts with price_):
- Monthly Price ID → Save as
STRIPE_PRICE_ID_MONTHLY - Yearly Price ID → Save as
STRIPE_PRICE_ID_YEARLY
Pro Tip: Use consistent billing intervals (
month,year) - this makes your logic much simpler later.
Configure Webhooks for Real-Time Sync
What we're doing: Setting up automatic synchronization between Stripe and your Supabase database.
Create Your Webhook Endpoint
- Go to Stripe Dashboard → Webhooks
- Click "+ Add endpoint"
- Set your Endpoint URL:
Replacehttps://your-project-ref.supabase.co/functions/v1/stripe-webhookyour-project-refwith your actual Supabase project reference
Subscribe to Essential Events
Select these webhook events (they're crucial for subscription management):
Core Subscription Events:
checkout.session.completed- New subscription createdcustomer.subscription.created- Subscription initiatedcustomer.subscription.updated- Plan changes, renewals, cancellationscustomer.subscription.deleted- Subscription permanently ended
Payment Events:
invoice.paid- Successful paymentinvoice.payment_succeeded- Payment confirmedinvoice.payment_failed- Failed payment (handle gracefully)
Get Your Webhook Secret
After creating the webhook, copy the Signing Secret (starts with whsec_).
Save this as STRIPE_WEBHOOK_SECRET - we'll use it to verify webhook authenticity.
Configure Customer Portal (Recommended Settings)
What we're doing: Setting up Stripe's customer portal with the right permissions.
- Navigate to Dashboard → Settings → Billing → Customer portal
Recommended Configuration
Enable these features:
- Update payment methods - Let customers manage their cards
- View invoice history - Transparency builds trust
Disable these features (recommended):
- Subscription cancellation - Keep this in your app for better UX
- Plan switching - Handle this in your app to avoid conflicts
Why disable some features? By handling cancellations and plan switches in your app, you maintain full control over the user experience and can implement your business logic (like "cancel at period end").
Get Your API Keys
Copy These Keys
- Secret Key (
sk_test_...) → Save asSTRIPE_SECRET_KEY - Publishable Key (
pk_test_...) → Use in frontend for Stripe.js
Security Note: Never expose your secret key in frontend code!
Configure Supabase Secrets
What we're doing: Securely storing your Stripe credentials in Supabase.
Set Your Secrets
Run these commands in your terminal:
# Navigate to your project directory
cd your-project-directory
# Set all required secrets
supabase secrets set STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxx
supabase secrets set STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxx
supabase secrets set STRIPE_PRICE_ID_MONTHLY=price_xxxxxxxxxxxxx
supabase secrets set STRIPE_PRICE_ID_YEARLY=price_xxxxxxxxxxxxx
Secret Reference Table
| Secret Name | Purpose | Format |
|---|---|---|
STRIPE_SECRET_KEY | Server-side Stripe API access | sk_test_... |
STRIPE_WEBHOOK_SECRET | Webhook signature verification | whsec_... |
STRIPE_PRICE_ID_MONTHLY | Monthly subscription price | price_... |
STRIPE_PRICE_ID_YEARLY | Yearly subscription price | price_... |
Checkpoint: Configuration Complete
At this point, you should have:
- A Stripe product with monthly and yearly prices
- A webhook endpoint configured with the right events
- Customer portal configured appropriately
- API keys copied and secrets set in Supabase
Next, we'll set up your database schema to store subscription data.
Phase 2: Database Schema Design
Now let's design a robust database schema that can handle all subscription scenarios including plan switching, cancellations, and edge cases.
Understanding the Data Model
The Challenge: We need to store subscription state that stays synchronized with Stripe while supporting complex operations like scheduled plan switches and graceful cancellations.
The Solution: A comprehensive user profile table that serves as our single source of truth for subscription state.
Database Schema
Core User Profiles Table
Add these columns to your user_profiles table (or create a dedicated subscriptions table):
-- Add these columns to your existing user_profiles table
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS subscription_status TEXT;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS subscription_current_period_end TIMESTAMPTZ;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS subscription_cancel_at_period_end BOOLEAN DEFAULT FALSE;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS subscription_enabled BOOLEAN DEFAULT FALSE;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS subscription_interval TEXT;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS pending_switch_interval TEXT;
ALTER TABLE user_profiles ADD COLUMN IF NOT EXISTS pending_switch_effective_at TIMESTAMPTZ;
Column Breakdown
| Column | Type | Purpose | Example Values |
|---|---|---|---|
stripe_customer_id | TEXT | Links user to Stripe customer | cus_ABC123... |
stripe_subscription_id | TEXT | Current active subscription | sub_XYZ789... |
subscription_status | TEXT | Current subscription state | active, trialing, past_due, canceled |
subscription_current_period_end | TIMESTAMPTZ | When current billing period ends | 2024-03-15 10:30:00+00 |
subscription_cancel_at_period_end | BOOLEAN | Is cancellation scheduled? | true or false |
subscription_enabled | BOOLEAN | App-level feature access control | true or false |
subscription_interval | TEXT | Current billing frequency | month or year |
pending_switch_interval | TEXT | Scheduled plan change | month, year, or null |
pending_switch_effective_at | TIMESTAMPTZ | When plan switch takes effect | 2024-03-15 10:30:00+00 |
Data Flow and State Management
Understanding the Column Relationships
Basic Subscription State:
subscription_status+subscription_enabled= User access levelsubscription_current_period_end= When next billing occurssubscription_interval= Current plan (monthly/yearly)
Cancellation Handling:
subscription_cancel_at_period_end=truemeans "cancel when period ends"- User keeps access until
subscription_current_period_end
Plan Switching:
pending_switch_interval= Plan user wants to switch topending_switch_effective_at= When the switch happens (usually next billing date)
Mapping Between UI and Database
This table shows how different parts of your system interact:
| UI Label | Database Value | Stripe Price ID (from env) |
|---|---|---|
| "Monthly Plan" | subscription_interval = 'month' | STRIPE_PRICE_ID_MONTHLY |
| "Yearly Plan" | subscription_interval = 'year' | STRIPE_PRICE_ID_YEARLY |
| "Switch to Monthly" | pending_switch_interval = 'month' | Resolved at switch time |
| "Switch to Yearly" | pending_switch_interval = 'year' | Resolved at switch time |
Example Data Scenarios
Scenario 1: Active Monthly Subscriber
{
"stripe_customer_id": "cus_ABC123",
"stripe_subscription_id": "sub_XYZ789",
"subscription_status": "active",
"subscription_current_period_end": "2024-03-15T10:30:00Z",
"subscription_cancel_at_period_end": false,
"subscription_enabled": true,
"subscription_interval": "month",
"pending_switch_interval": null,
"pending_switch_effective_at": null
}
Scenario 2: User Scheduled to Switch from Monthly to Yearly
{
"stripe_customer_id": "cus_ABC123",
"stripe_subscription_id": "sub_XYZ789",
"subscription_status": "active",
"subscription_current_period_end": "2024-03-15T10:30:00Z",
"subscription_cancel_at_period_end": false,
"subscription_enabled": true,
"subscription_interval": "month",
"pending_switch_interval": "year",
"pending_switch_effective_at": "2024-03-15T10:30:00Z"
}
Scenario 3: User Canceled (but still has access until period end)
{
"stripe_customer_id": "cus_ABC123",
"stripe_subscription_id": "sub_XYZ789",
"subscription_status": "active",
"subscription_current_period_end": "2024-03-15T10:30:00Z",
"subscription_cancel_at_period_end": true,
"subscription_enabled": true,
"subscription_interval": "month",
"pending_switch_interval": null,
"pending_switch_effective_at": null
}
Access Control Logic
Here's how to determine if a user has active subscription access:
function hasActiveSubscription(profile: UserProfile): boolean {
const {
subscription_status,
subscription_cancel_at_period_end,
subscription_current_period_end
} = profile;
// Active or trialing = definitely has access
if (subscription_status === 'active' || subscription_status === 'trialing') {
return true;
}
// Canceled but still within paid period = keep access
if (subscription_cancel_at_period_end && subscription_current_period_end) {
return new Date(subscription_current_period_end) > new Date();
}
return false;
}
Checkpoint: Database Ready
You now have:
- A comprehensive database schema for subscription management
- Understanding of how data flows between UI, database, and Stripe
- Logic for determining user access levels
- Support for complex scenarios like plan switching and cancellations
Next, we'll implement the core subscription flow starting with new subscriptions.
Phase 3: Implementing Core Subscription Flow
Let's build the heart of our system: creating new subscriptions. This is where users go from free to paid subscribers.
Understanding the Complete Flow
Here's what happens when a user subscribes:
1. User clicks "Subscribe to Monthly/Yearly" in your UI
2. Frontend calls your `create-checkout-session` Edge Function
3. Edge Function creates/finds Stripe Customer
4. Stripe Checkout Session created with correct pricing
5. User completes payment in Stripe's secure checkout
6. Stripe sends `checkout.session.completed` webhook
7. Webhook updates your Supabase database
8. User now has active subscription access
Create Checkout Session Edge Function
What this does: Securely creates a Stripe checkout session with the right pricing and customer information.
Create the Edge Function
Create a file: supabase/functions/create-checkout-session/index.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
// Initialize Stripe with your secret key
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia'
});
// Supabase setup
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
Deno.serve(async (req) => {
try {
// 1. Parse request and validate user
const { interval } = await req.json();
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401
});
}
// Create authenticated Supabase client
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (userError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401
});
}
// 2. Resolve the correct price ID based on interval
const priceId = interval === 'yearly'
? Deno.env.get('STRIPE_PRICE_ID_YEARLY')
: Deno.env.get('STRIPE_PRICE_ID_MONTHLY');
if (!priceId) {
console.error(`Price ID not found for interval: ${interval}`);
return new Response(JSON.stringify({ error: 'Price not configured' }), {
status: 500
});
}
// 3. Get or create Stripe customer
const { data: profile } = await supabase
.from('user_profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single();
let customerId = profile?.stripe_customer_id;
if (!customerId) {
// Create new Stripe customer
const customer = await stripe.customers.create({
email: user.email!,
metadata: {
supabase_user_id: user.id
},
});
customerId = customer.id;
// Save customer ID to database
await supabase
.from('user_profiles')
.update({ stripe_customer_id: customerId })
.eq('id', user.id);
}
// 4. Create Stripe Checkout Session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
line_items: [{
price: priceId,
quantity: 1
}],
success_url: `${new URL(req.url).origin}/dashboard?checkout=success`,
cancel_url: `${new URL(req.url).origin}/subscription`,
subscription_data: {
metadata: {
supabase_user_id: user.id
},
},
// Optional: Add customer email prefill
customer_update: {
address: 'auto',
},
});
// 5. Return checkout URL
return new Response(JSON.stringify({
url: session.url,
session_id: session.id
}), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
},
});
} catch (error) {
console.error('Error creating checkout session:', error);
return new Response(JSON.stringify({
error: 'Internal server error'
}), {
status: 500
});
}
});
Key Features of This Implementation
Security & Authentication:
- Validates user authentication via Supabase Auth
- Uses service role key for secure database operations
- Includes proper error handling
Customer Management:
- Creates Stripe customer if doesn't exist
- Links Stripe customer to Supabase user ID
- Stores customer ID for future operations
Flexible Pricing:
- Dynamically selects price based on
intervalparameter - Supports both monthly and yearly subscriptions
- Environment-based price configuration
Frontend Integration
What this does: Shows how to call your checkout function from your React/Next.js frontend.
Frontend Implementation Example
// utils/subscription.ts
export async function createCheckoutSession(interval: 'monthly' | 'yearly') {
const supabase = createClientComponentClient();
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
throw new Error('User not authenticated');
}
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${session.access_token}`,
},
body: JSON.stringify({ interval }),
});
if (!response.ok) {
throw new Error('Failed to create checkout session');
}
const { url } = await response.json();
return url;
}
React Component Example
// components/SubscriptionPlans.tsx
export function SubscriptionPlans() {
const [loading, setLoading] = useState<string | null>(null);
const handleSubscribe = async (interval: 'monthly' | 'yearly') => {
try {
setLoading(interval);
const checkoutUrl = await createCheckoutSession(interval);
window.location.href = checkoutUrl;
} catch (error) {
console.error('Subscription error:', error);
// Show error toast
} finally {
setLoading(null);
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Monthly Plan */}
<div className="border rounded-lg p-6">
<h3 className="text-xl font-semibold">Monthly Plan</h3>
<p className="text-3xl font-bold">$13.99<span className="text-sm">/month</span></p>
<button
onClick={() => handleSubscribe('monthly')}
disabled={loading === 'monthly'}
className="w-full bg-blue-600 text-white rounded-lg py-2 mt-4"
>
{loading === 'monthly' ? 'Loading...' : 'Subscribe Monthly'}
</button>
</div>
{/* Yearly Plan */}
<div className="border rounded-lg p-6">
<h3 className="text-xl font-semibold">Yearly Plan</h3>
<p className="text-3xl font-bold">$129<span className="text-sm">/year</span></p>
<p className="text-sm text-gray-600">Save 22%!</p>
<button
onClick={() => handleSubscribe('yearly')}
disabled={loading === 'yearly'}
className="w-full bg-green-600 text-white rounded-lg py-2 mt-4"
>
{loading === 'yearly' ? 'Loading...' : 'Subscribe Yearly'}
</button>
</div>
</div>
);
}
Subscription Access Logic
What this does: Determines whether a user has active subscription access in your app.
Implementation in Auth Context
// src/contexts/AuthContext.tsx
import { useMemo } from 'react';
export function useSubscriptionAccess(profile: UserProfile) {
const hasActiveSubscription = useMemo(() => {
if (!profile) return false;
const {
subscription_status,
subscription_cancel_at_period_end,
subscription_current_period_end
} = profile;
// No subscription data = no access
if (!subscription_status) return false;
// Active or trialing = definitely has access
if (subscription_status === 'active' || subscription_status === 'trialing') {
return true;
}
// Canceled but still within paid period = keep access until period end
if (subscription_cancel_at_period_end && subscription_current_period_end) {
const periodEnd = new Date(subscription_current_period_end);
const now = new Date();
return periodEnd > now;
}
// All other statuses (canceled, unpaid, etc.) = no access
return false;
}, [
profile?.subscription_status,
profile?.subscription_cancel_at_period_end,
profile?.subscription_current_period_end
]);
return {
hasActiveSubscription,
isTrialing: profile?.subscription_status === 'trialing',
isCanceled: profile?.subscription_cancel_at_period_end === true,
periodEnd: profile?.subscription_current_period_end,
currentPlan: profile?.subscription_interval,
};
}
Usage in Components
// components/FeatureGate.tsx
export function FeatureGate({ children }: { children: React.ReactNode }) {
const { profile } = useAuth();
const { hasActiveSubscription, isCanceled, periodEnd } = useSubscriptionAccess(profile);
if (!hasActiveSubscription) {
return (
<div className="text-center p-8 border rounded-lg">
<h3 className="text-xl font-semibold mb-4">Premium Feature</h3>
<p className="text-gray-600 mb-4">
This feature requires an active subscription.
</p>
<Link href="/subscription">
<button className="bg-blue-600 text-white px-6 py-2 rounded-lg">
View Plans
</button>
</Link>
</div>
);
}
return (
<>
{children}
{isCanceled && periodEnd && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mt-4">
<p className="text-yellow-800">
Warning: Your subscription is canceled but you have access until{' '}
{new Date(periodEnd).toLocaleDateString()}
</p>
</div>
)}
</>
);
}
Checkpoint: New Subscriptions Work
You now have:
- Secure checkout session creation
- Frontend integration for subscription plans
- Access control logic that handles edge cases
- User-friendly components for subscription gates
Next, we'll handle the webhook that processes successful subscriptions and keeps your database in sync.
Phase 4: Advanced Subscription Management
Now that users can subscribe, let's implement the advanced features that make your subscription system truly robust: cancellations, resumptions, and plan switching.
Understanding the Three Operations
Here's what each operation does:
| Operation | What It Does | When It Takes Effect | User Impact |
|---|---|---|---|
| Cancel | Schedule subscription to end | At period end | Keeps access until billing period ends |
| Resume | Uncancel a canceled subscription | Immediately | Continues billing normally |
| Switch Plan | Change between monthly/yearly | At next billing date | Price changes at next renewal |
Cancel Subscription (Graceful)
Philosophy: When users cancel, we don't want to punish them by immediately cutting off access. Instead, we let them use what they've paid for until their current period ends.
How Graceful Cancellation Works
User has Monthly Plan (Feb 1 - Mar 1)
├── Feb 15: User clicks "Cancel"
├── Feb 15-28: Still has full access (paid period continues)
└── Mar 1: Access ends, subscription becomes inactive
Implementation: Cancel Subscription Edge Function
Create: supabase/functions/cancel-subscription/index.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia'
});
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
Deno.serve(async (req) => {
try {
// 1. Authenticate user
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401
});
}
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: { user }, error: userError } = await supabase.auth.getUser();
if (userError || !user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401
});
}
// 2. Get user's subscription data
const { data: profile, error: profileError } = await supabase
.from('user_profiles')
.select('stripe_subscription_id, pending_switch_interval, pending_switch_effective_at')
.eq('id', user.id)
.single();
if (profileError || !profile?.stripe_subscription_id) {
return new Response(JSON.stringify({
error: 'No active subscription found'
}), { status: 404 });
}
const subscriptionId = profile.stripe_subscription_id;
// 3. If there's a pending plan switch, cancel it first
if (profile.pending_switch_interval) {
try {
// Get subscription to find schedule
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
if (subscription.schedule) {
await stripe.subscriptionSchedules.cancel(subscription.schedule);
}
} catch (scheduleError) {
console.warn('Could not cancel subscription schedule:', scheduleError);
// Continue with cancellation even if schedule cleanup fails
}
}
// 4. Cancel subscription at period end in Stripe
const updatedSubscription = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
});
// 5. Update database immediately (webhook will confirm later)
await supabase
.from('user_profiles')
.update({
subscription_cancel_at_period_end: true,
subscription_current_period_end: new Date(updatedSubscription.current_period_end * 1000).toISOString(),
// Clear any pending switches
pending_switch_interval: null,
pending_switch_effective_at: null,
})
.eq('id', user.id);
return new Response(JSON.stringify({
success: true,
message: 'Subscription canceled successfully',
access_until: new Date(updatedSubscription.current_period_end * 1000).toISOString()
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error canceling subscription:', error);
return new Response(JSON.stringify({
error: 'Failed to cancel subscription'
}), {
status: 500
});
}
});
Frontend Integration for Cancellation
// components/CancelSubscription.tsx
export function CancelSubscription() {
const [isConfirming, setIsConfirming] = useState(false);
const [loading, setLoading] = useState(false);
const handleCancel = async () => {
try {
setLoading(true);
const response = await fetch('/api/cancel-subscription', {
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
},
});
if (response.ok) {
const { access_until } = await response.json();
// Show success message with access date
toast.success(`Subscription canceled. You'll have access until ${new Date(access_until).toLocaleDateString()}`);
// Refresh user data
router.refresh();
} else {
throw new Error('Cancellation failed');
}
} catch (error) {
toast.error('Failed to cancel subscription');
} finally {
setLoading(false);
setIsConfirming(false);
}
};
if (!isConfirming) {
return (
<button
onClick={() => setIsConfirming(true)}
className="text-red-600 hover:text-red-700"
>
Cancel Subscription
</button>
);
}
return (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="font-semibold text-red-800">Are you sure?</h4>
<p className="text-red-700 text-sm mt-1">
Your subscription will be canceled at the end of your current billing period.
You'll keep access until then.
</p>
<div className="mt-3 space-x-3">
<button
onClick={handleCancel}
disabled={loading}
className="bg-red-600 text-white px-4 py-2 rounded text-sm hover:bg-red-700"
>
{loading ? 'Canceling...' : 'Yes, Cancel'}
</button>
<button
onClick={() => setIsConfirming(false)}
className="text-gray-600 hover:text-gray-700 text-sm"
>
Never mind
</button>
</div>
</div>
);
}
Resume Subscription
When to use: User has a canceled subscription (cancel_at_period_end = true) but wants to continue.
Implementation: Resume Subscription Edge Function
Create: supabase/functions/resume-subscription/index.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia'
});
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
Deno.serve(async (req) => {
try {
// 1. Authenticate user
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401
});
}
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
global: { headers: { Authorization: authHeader } },
});
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401
});
}
// 2. Verify subscription is eligible for resume
const { data: profile } = await supabase
.from('user_profiles')
.select('stripe_subscription_id, subscription_cancel_at_period_end')
.eq('id', user.id)
.single();
if (!profile?.stripe_subscription_id) {
return new Response(JSON.stringify({
error: 'No subscription found'
}), { status: 404 });
}
if (!profile.subscription_cancel_at_period_end) {
return new Response(JSON.stringify({
error: 'Subscription is not canceled'
}), { status: 400 });
}
// 3. Resume subscription in Stripe
const updatedSubscription = await stripe.subscriptions.update(
profile.stripe_subscription_id,
{ cancel_at_period_end: false }
);
// 4. Update database
await supabase
.from('user_profiles')
.update({
subscription_cancel_at_period_end: false,
subscription_current_period_end: new Date(updatedSubscription.current_period_end * 1000).toISOString(),
})
.eq('id', user.id);
return new Response(JSON.stringify({
success: true,
message: 'Subscription resumed successfully'
}), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Error resuming subscription:', error);
return new Response(JSON.stringify({
error: 'Failed to resume subscription'
}), { status: 500 });
}
});
Plan Switching (Monthly ↔ Yearly)
Key Concept: Plan switches are scheduled, not immediate. They take effect at the next billing date to avoid prorated charges and complexity.
How Plan Switching Works
User has Monthly Plan ($13.99)
├── Feb 15: User switches to yearly
├── Feb 15-28: Still paying monthly rate
├── Mar 1: Plan switch takes effect → now $129/year
└── Next billing: March 2025 (full year from switch date)
Implementation: Switch Plan Edge Function
Create: supabase/functions/switch-subscription-plan/index.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia'
});
Deno.serve(async (req) => {
try {
const { new_interval } = await req.json(); // 'monthly' or 'yearly'
// Validate input
if (!['monthly', 'yearly'].includes(new_interval)) {
return new Response(JSON.stringify({
error: 'Invalid interval. Must be "monthly" or "yearly"'
}), { status: 400 });
}
// Convert UI interval to database interval
const targetInterval = new_interval === 'yearly' ? 'year' : 'month';
const targetPriceId = targetInterval === 'year'
? Deno.env.get('STRIPE_PRICE_ID_YEARLY')
: Deno.env.get('STRIPE_PRICE_ID_MONTHLY');
if (!targetPriceId) {
return new Response(JSON.stringify({
error: 'Price configuration not found'
}), { status: 500 });
}
// Authentication and user validation...
// (Same pattern as other functions)
// Get current subscription state
const { data: profile } = await supabase
.from('user_profiles')
.select('stripe_subscription_id, subscription_interval, pending_switch_interval, subscription_cancel_at_period_end')
.eq('id', user.id)
.single();
if (!profile?.stripe_subscription_id) {
return new Response(JSON.stringify({
error: 'No active subscription'
}), { status: 404 });
}
// Don't allow switching if subscription is canceled
if (profile.subscription_cancel_at_period_end) {
return new Response(JSON.stringify({
error: 'Cannot switch plan on canceled subscription'
}), { status: 400 });
}
const currentInterval = profile.subscription_interval;
const pendingInterval = profile.pending_switch_interval;
// Decision logic
if (targetInterval === currentInterval && !pendingInterval) {
// User selected current plan with no pending change = no-op
return new Response(JSON.stringify({
result: 'noop',
message: 'Already on selected plan'
}));
}
if (targetInterval === currentInterval && pendingInterval) {
// User wants to revert pending switch = cancel schedule
const subscription = await stripe.subscriptions.retrieve(profile.stripe_subscription_id);
if (subscription.schedule) {
await stripe.subscriptionSchedules.cancel(subscription.schedule);
}
await supabase
.from('user_profiles')
.update({
pending_switch_interval: null,
pending_switch_effective_at: null,
})
.eq('id', user.id);
return new Response(JSON.stringify({
result: 'reverted',
message: 'Pending plan change canceled'
}));
}
if (targetInterval === pendingInterval) {
// User selected same plan that's already pending = no-op
return new Response(JSON.stringify({
result: 'noop',
message: 'This plan change is already scheduled'
}));
}
// Create or update subscription schedule for the switch
const subscription = await stripe.subscriptions.retrieve(profile.stripe_subscription_id);
const currentPeriodEnd = subscription.current_period_end;
// Cancel existing schedule if any
if (subscription.schedule) {
await stripe.subscriptionSchedules.cancel(subscription.schedule);
}
// Create new schedule
const schedule = await stripe.subscriptionSchedules.create({
from_subscription: profile.stripe_subscription_id,
phases: [
{
// Phase 1: Keep current plan until period end
start_date: subscription.current_period_start,
end_date: currentPeriodEnd,
items: [{
price: subscription.items.data[0].price.id,
quantity: 1,
}],
},
{
// Phase 2: Switch to new plan from period end
start_date: currentPeriodEnd,
items: [{
price: targetPriceId,
quantity: 1,
}],
},
],
});
// Update database with pending switch info
await supabase
.from('user_profiles')
.update({
pending_switch_interval: targetInterval,
pending_switch_effective_at: new Date(currentPeriodEnd * 1000).toISOString(),
})
.eq('id', user.id);
return new Response(JSON.stringify({
result: 'scheduled',
message: `Plan switch scheduled for ${new Date(currentPeriodEnd * 1000).toLocaleDateString()}`,
effective_date: new Date(currentPeriodEnd * 1000).toISOString(),
new_plan: new_interval,
}));
} catch (error) {
console.error('Error switching plan:', error);
return new Response(JSON.stringify({
error: 'Failed to switch plan'
}), { status: 500 });
}
});
Handling Edge Cases
Edge Case 1: Cancel While Switching
Scenario: User has scheduled a plan switch but then decides to cancel entirely.
Solution: Our cancel function automatically clears pending switches.
Edge Case 2: Switch While Canceled
Prevention: Don't show plan switching UI when subscription_cancel_at_period_end = true.
// In your subscription management component
const { isCanceled } = useSubscriptionAccess(profile);
return (
<div>
{isCanceled ? (
<div>
<p>Your subscription is canceled.</p>
<ResumeButton />
</div>
) : (
<div>
<PlanSwitchingComponent />
<CancelButton />
</div>
)}
</div>
);
Checkpoint: Advanced Features Complete
You now have:
- Graceful subscription cancellation (access until period end)
- Subscription resumption for canceled subscriptions
- Scheduled plan switching between monthly and yearly
- Proper handling of edge cases and conflicts
- User-friendly frontend components
Next, we'll implement the webhook system that keeps everything synchronized with Stripe.
Phase 5: Webhook Integration - The Sync Engine
Webhooks are the backbone of your subscription system. They ensure your database stays perfectly synchronized with Stripe, even when changes happen outside your app (like failed payments or changes in the Stripe dashboard).
Understanding Webhook Flow
Stripe Event Occurs → Webhook Sent → Your Function → Database Updated → User Sees Changes
Key Events We Handle:
| Stripe Event | What Triggers It | What We Do |
|---|---|---|
checkout.session.completed | User completes payment | Create subscription record |
customer.subscription.updated | Plan changes, renewals, cancellations | Sync subscription state |
customer.subscription.deleted | Subscription permanently ends | Disable user access |
invoice.payment_succeeded | Successful payment | Log payment, ensure access |
invoice.payment_failed | Failed payment | Mark account as past due |
Webhook Security & Setup
Complete Webhook Implementation
Create: supabase/functions/stripe-webhook/index.ts
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, {
apiVersion: '2024-11-20.acacia'
});
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
const webhookSecret = Deno.env.get('STRIPE_WEBHOOK_SECRET')!;
Deno.serve(async (req) => {
try {
// 1. Verify webhook signature
const signature = req.headers.get('stripe-signature');
const body = await req.text();
if (!signature) {
console.error('Missing stripe-signature header');
return new Response('Missing signature', { status: 400 });
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error('Webhook signature verification failed:', err);
return new Response('Invalid signature', { status: 400 });
}
console.log(`Processing webhook: ${event.type} (${event.id})`);
// 2. Create Supabase client with service role
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// 3. Route to appropriate handler
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session, supabase);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionUpdated(event.data.object as Stripe.Subscription, supabase);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription, supabase);
break;
case 'invoice.payment_succeeded':
await handlePaymentSucceeded(event.data.object as Stripe.Invoice, supabase);
break;
case 'invoice.payment_failed':
await handlePaymentFailed(event.data.object as Stripe.Invoice, supabase);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return new Response(JSON.stringify({ received: true }), {
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
console.error('Webhook processing error:', error);
return new Response('Internal server error', { status: 500 });
}
});
// Handler Functions
async function handleCheckoutCompleted(
session: Stripe.Checkout.Session,
supabase: any
) {
console.log('Processing checkout completion:', session.id);
if (session.mode !== 'subscription' || !session.subscription) {
console.log('Not a subscription checkout, skipping');
return;
}
// Get subscription details from Stripe
const subscription = await stripe.subscriptions.retrieve(session.subscription as string);
const customerId = session.customer as string;
// Find user by customer ID
const { data: profile } = await supabase
.from('user_profiles')
.select('id')
.eq('stripe_customer_id', customerId)
.single();
if (!profile) {
console.error(`User not found for customer ${customerId}`);
return;
}
// Extract subscription data
const priceId = subscription.items.data[0].price.id;
const interval = subscription.items.data[0].price.recurring?.interval || 'month';
// Update user profile with subscription data
const updateData = {
stripe_subscription_id: subscription.id,
subscription_status: subscription.status,
subscription_current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
subscription_cancel_at_period_end: subscription.cancel_at_period_end,
subscription_enabled: true,
subscription_interval: interval,
// Clear any pending switches on new subscription
pending_switch_interval: null,
pending_switch_effective_at: null,
};
await supabase
.from('user_profiles')
.update(updateData)
.eq('id', profile.id);
console.log(`Subscription created for user ${profile.id}`);
}
async function handleSubscriptionUpdated(
subscription: Stripe.Subscription,
supabase: any
) {
console.log('Processing subscription update:', subscription.id);
// Find user by subscription ID
const { data: profile } = await supabase
.from('user_profiles')
.select('id, pending_switch_interval, pending_switch_effective_at')
.eq('stripe_subscription_id', subscription.id)
.single();
if (!profile) {
console.error(`User not found for subscription ${subscription.id}`);
return;
}
const interval = subscription.items.data[0].price.recurring?.interval || 'month';
// Check if this update represents a completed plan switch
const shouldClearPendingSwitch =
subscription.cancel_at_period_end || // Canceled
(profile.pending_switch_effective_at &&
Date.now() >= new Date(profile.pending_switch_effective_at).getTime() &&
interval === profile.pending_switch_interval); // Switch completed
const updateData = {
subscription_status: subscription.status,
subscription_current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
subscription_cancel_at_period_end: subscription.cancel_at_period_end,
subscription_interval: interval,
// Clear pending switch if conditions are met
...(shouldClearPendingSwitch && {
pending_switch_interval: null,
pending_switch_effective_at: null,
}),
};
await supabase
.from('user_profiles')
.update(updateData)
.eq('id', profile.id);
console.log(`Subscription updated for user ${profile.id}`);
}
async function handleSubscriptionDeleted(
subscription: Stripe.Subscription,
supabase: any
) {
console.log('Processing subscription deletion:', subscription.id);
// Find and disable user's subscription
const { data: profile } = await supabase
.from('user_profiles')
.select('id')
.eq('stripe_subscription_id', subscription.id)
.single();
if (profile) {
await supabase
.from('user_profiles')
.update({
subscription_status: 'canceled',
subscription_enabled: false,
stripe_subscription_id: null,
pending_switch_interval: null,
pending_switch_effective_at: null,
})
.eq('id', profile.id);
console.log(`Subscription disabled for user ${profile.id}`);
}
}
async function handlePaymentSucceeded(
invoice: Stripe.Invoice,
supabase: any
) {
console.log('Processing successful payment:', invoice.id);
if (!invoice.subscription) return;
// Ensure user's subscription is marked as active
const { data: profile } = await supabase
.from('user_profiles')
.select('id')
.eq('stripe_subscription_id', invoice.subscription)
.single();
if (profile) {
await supabase
.from('user_profiles')
.update({
subscription_status: 'active',
subscription_enabled: true,
})
.eq('id', profile.id);
// Optionally: Log the payment in a payments table
// await logPayment(invoice, profile.id, supabase);
}
}
async function handlePaymentFailed(
invoice: Stripe.Invoice,
supabase: any
) {
console.log('Processing failed payment:', invoice.id);
if (!invoice.subscription) return;
// Mark subscription as past due
const { data: profile } = await supabase
.from('user_profiles')
.select('id')
.eq('stripe_subscription_id', invoice.subscription)
.single();
if (profile) {
await supabase
.from('user_profiles')
.update({
subscription_status: 'past_due',
// Keep subscription_enabled true for grace period
// You might want to disable after multiple failed attempts
})
.eq('id', profile.id);
console.log(`Subscription marked as past_due for user ${profile.id}`);
// Optionally: Send notification email
// await sendPaymentFailedEmail(profile.id);
}
}
Checkpoint: Webhooks Working
You now have:
- Secure webhook verification with signature checking
- Comprehensive event handling for all subscription events
- Reliable sync between Stripe and your database
- Production-ready webhook implementation
Your subscription system is now fully functional! Next, we'll cover UI patterns and best practices.
Part 9: UI Presentation Rules
Plan Card
- Use
subscription_intervalto display the correct amount:month→ monthly priceyear→ yearly price
Canceling Badge and Alerts
- Use
subscription_cancel_at_period_end - Use
subscription_current_period_endfor "Access until" date
Pending Switch Banner
- Use
pending_switch_intervalandpending_switch_effective_at - Example: "Your plan will switch to Yearly on 2025-04-15"
Date Display
Format billing timestamps in UTC (or a consistent timezone) to avoid day-shift issues across timezones.
Part 10: Sequence Overview
[Active Plan]
│
├── Switch monthly/yearly → [Scheduled Switch] → At effective date → [Renewed with New Interval]
│
├── Cancel → [Cancel at Period End] → Period end → [Ended]
│ │
│ └── Resume → [Active Plan]
│
└── [Scheduled Switch] + Cancel → [Cancel at Period End] (switch removed)
Best Practices
- Read from your DB, not Stripe: App logic should use
user_profiles; Stripe is the source of truth only via webhooks. - Handle webhook idempotency: Stripe may retry webhooks. Make your handlers idempotent where possible.
- Log webhook failures: Monitor Stripe webhook logs for delivery and processing errors.
- Keep portal limited: Disable cancel/switch in the portal if you manage these in-app.
- Test all flows: Use Stripe test mode and test cards for checkout, cancel, resume, and switch flows.
Common Issues and Solutions
Webhook not receiving events
Solution: Ensure the webhook URL is correct, the endpoint is deployed, and Supabase secrets are set. Check Stripe Dashboard → Webhooks for delivery status.
Subscription state out of sync
Solution: Verify webhook events are being processed. Check Edge Function logs. Manual changes in Stripe Dashboard or portal sync via webhooks; if they don’t appear, inspect webhook delivery.
User loses access immediately on cancel
Solution: Ensure hasActiveSubscription treats cancel_at_period_end = true as active until subscription_current_period_end.
Pending switch cleared too early
Solution: In customer.subscription.updated, only clear pending switch when the event time is at or after pending_switch_effective_at and the interval matches.
Cancel and switch conflict
Solution: When canceling, always release the subscription schedule and clear pending switch fields. Hide switch UI when subscription_cancel_at_period_end = true.
Conclusion
A robust Stripe + Supabase subscription system requires:
- Correct Stripe product, prices, webhook, and portal configuration
- A clear data model in
user_profilesfor subscription state - Edge Functions for checkout, cancel, resume, and plan switching
- Reliable webhook handling for ongoing sync
- Careful handling of cancel-at-period-end, resume, and plan switching
- Logic for corner cases like cancel-during-switch
By following this guide, you can support monthly and yearly plans with cancel, resume, and plan switching while keeping state consistent between Stripe and Supabase.
Phase 6: Building User-Friendly Interfaces
Now let's create intuitive UI components that make complex subscription management feel simple for your users.
Smart Subscription Status Display
Your UI should automatically adapt based on the user's subscription state. Here are the key patterns:
Comprehensive Status Component
// components/SubscriptionStatus.tsx
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Alert, AlertDescription } from './ui/alert';
interface SubscriptionStatusProps {
profile: UserProfile;
onCancel: () => void;
onResume: () => void;
onSwitchPlan: (interval: 'monthly' | 'yearly') => void;
}
export function SubscriptionStatus({ profile, onCancel, onResume, onSwitchPlan }: SubscriptionStatusProps) {
const {
subscription_status,
subscription_interval,
subscription_cancel_at_period_end,
subscription_current_period_end,
pending_switch_interval,
pending_switch_effective_at,
} = profile;
const isActive = subscription_status === 'active' || subscription_status === 'trialing';
const isCanceled = subscription_cancel_at_period_end;
const hasPendingSwitch = !!pending_switch_interval;
// Format dates consistently
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
// Status badge
const getStatusBadge = () => {
if (!isActive) {
return <Badge variant="destructive">Inactive</Badge>;
}
if (isCanceled) {
return <Badge variant="secondary">Canceled</Badge>;
}
if (subscription_status === 'trialing') {
return <Badge variant="outline">Trial</Badge>;
}
return <Badge variant="default">Active</Badge>;
};
// Current plan display
const getCurrentPlanDisplay = () => {
const planName = subscription_interval === 'year' ? 'Yearly Plan' : 'Monthly Plan';
const price = subscription_interval === 'year' ? '$129/year' : '$13.99/month';
return (
<div className="bg-white border rounded-lg p-4">
<div className="flex justify-between items-center">
<div>
<h3 className="font-semibold">{planName}</h3>
<p className="text-2xl font-bold text-green-600">{price}</p>
</div>
{getStatusBadge()}
</div>
</div>
);
};
return (
<div className="space-y-4">
{/* Current Plan */}
{getCurrentPlanDisplay()}
{/* Pending Switch Alert */}
{hasPendingSwitch && !isCanceled && (
<Alert>
<AlertDescription>
Your plan will switch to{' '}
<strong>{pending_switch_interval === 'year' ? 'Yearly' : 'Monthly'}</strong>{' '}
on {formatDate(pending_switch_effective_at!)}
</AlertDescription>
</Alert>
)}
{/* Cancellation Alert */}
{isCanceled && subscription_current_period_end && (
<Alert variant="destructive">
<AlertDescription>
Warning: Your subscription is canceled. You'll have access until{' '}
<strong>{formatDate(subscription_current_period_end)}</strong>
</AlertDescription>
</Alert>
)}
{/* Action Buttons */}
<div className="space-y-2">
{isActive && !isCanceled && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{/* Plan Switch Buttons */}
{subscription_interval === 'month' && (
<Button
onClick={() => onSwitchPlan('yearly')}
variant="outline"
disabled={hasPendingSwitch && pending_switch_interval === 'year'}
>
{hasPendingSwitch && pending_switch_interval === 'year'
? 'Switch to Yearly (Scheduled)'
: 'Switch to Yearly & Save 22%'
}
</Button>
)}
{subscription_interval === 'year' && (
<Button
onClick={() => onSwitchPlan('monthly')}
variant="outline"
disabled={hasPendingSwitch && pending_switch_interval === 'month'}
>
{hasPendingSwitch && pending_switch_interval === 'month'
? 'Switch to Monthly (Scheduled)'
: 'Switch to Monthly'
}
</Button>
)}
<Button onClick={onCancel} variant="destructive">
Cancel Subscription
</Button>
</div>
)}
{isCanceled && (
<Button onClick={onResume} variant="default" className="w-full">
Resume Subscription
</Button>
)}
</div>
{/* Billing Info */}
{isActive && subscription_current_period_end && (
<div className="text-sm text-gray-600 bg-gray-50 p-3 rounded">
Next billing date: <strong>{formatDate(subscription_current_period_end)}</strong>
</div>
)}
</div>
);
}
Plan Comparison Component
Help users understand the value of different plans:
// components/PlanComparison.tsx
export function PlanComparison({ currentInterval, onSelectPlan }: {
currentInterval?: string;
onSelectPlan: (interval: 'monthly' | 'yearly') => void;
}) {
const plans = [
{
name: 'Monthly',
interval: 'monthly' as const,
price: '$13.99',
period: '/month',
savings: null,
features: ['All premium features', 'Priority support', 'Cancel anytime'],
},
{
name: 'Yearly',
interval: 'yearly' as const,
price: '$129',
period: '/year',
savings: 'Save 22%',
features: ['All premium features', 'Priority support', 'Cancel anytime', '2 months free!'],
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{plans.map((plan) => {
const isCurrent = currentInterval === (plan.interval === 'yearly' ? 'year' : 'month');
return (
<div
key={plan.interval}
className={`border rounded-xl p-6 ${
isCurrent
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-200'
: 'border-gray-200'
}`}
>
<div className="text-center">
<h3 className="text-xl font-semibold">{plan.name}</h3>
{plan.savings && (
<div className="bg-green-100 text-green-800 text-sm font-medium px-3 py-1 rounded-full inline-block mt-2">
{plan.savings}
</div>
)}
<div className="mt-4">
<span className="text-4xl font-bold">{plan.price}</span>
<span className="text-gray-600">{plan.period}</span>
</div>
</div>
<ul className="mt-6 space-y-2">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-center text-sm">
<svg className="h-4 w-4 text-green-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
{feature}
</li>
))}
</ul>
<Button
onClick={() => onSelectPlan(plan.interval)}
disabled={isCurrent}
className="w-full mt-6"
variant={isCurrent ? "secondary" : "default"}
>
{isCurrent ? 'Current Plan' : `Choose ${plan.name}`}
</Button>
</div>
);
})}
</div>
);
}
Production Best Practices & Troubleshooting
Production Deployment Checklist
Pre-Launch Verification
- Test all flows in Stripe test mode
- Verify webhook delivery using Stripe CLI
- Test edge cases: cancellation, resumption, plan switching
- Validate error handling with invalid payment methods
- Check mobile responsiveness of subscription components
- Verify timezone handling for billing dates
- Test idempotency of webhook handlers
Security Checklist
- Environment secrets properly set in Supabase
- Webhook signature verification implemented
- User authentication required for all subscription endpoints
- Rate limiting on subscription action endpoints
- Input validation on all Edge Function parameters
Common Issues & Solutions
Issue 1: Webhooks Not Being Received
Symptoms: Database not updating after Stripe events
Debug Steps:
- Check Stripe Dashboard → Webhooks → Recent deliveries
- Verify webhook URL is accessible:
https://your-project.supabase.co/functions/v1/stripe-webhook - Check Supabase Edge Function logs
- Verify
STRIPE_WEBHOOK_SECRETis set correctly
Solution:
# Test webhook connectivity
curl -X POST https://your-project.supabase.co/functions/v1/stripe-webhook \
-H "Content-Type: application/json" \
-d '{"test": true}'
Issue 2: User Loses Access Immediately on Cancel
Symptoms: Users can't access features right after cancellation
Root Cause: Access logic not accounting for cancel_at_period_end state
Solution: Update your access logic:
const hasAccess = (profile) => {
// Active or trialing = access
if (['active', 'trialing'].includes(profile.subscription_status)) {
return true;
}
// Canceled but still in paid period = keep access
if (profile.subscription_cancel_at_period_end && profile.subscription_current_period_end) {
return new Date(profile.subscription_current_period_end) > new Date();
}
return false;
};
Issue 3: Plan Switches Not Working
Symptoms: Plan switch appears to complete but doesn't take effect
Debug Steps:
- Check if subscription schedule was created in Stripe
- Verify
pending_switch_*fields are set in database - Check webhook processing for
customer.subscription.updated
Solution: Ensure your webhook clears pending switch fields only when appropriate:
const shouldClearPendingSwitch =
subscription.cancel_at_period_end ||
(profile.pending_switch_effective_at &&
Date.now() >= new Date(profile.pending_switch_effective_at).getTime() &&
interval === profile.pending_switch_interval);
Final Success: You've Built a Production-Ready System!
Congratulations! You now have:
- Secure Stripe integration with proper webhook verification
- Flexible subscription management supporting monthly/yearly plans
- Graceful cancellation system that maintains user access until period end
- Seamless plan switching with scheduled transitions
- Robust error handling and edge case management
- User-friendly interfaces for all subscription operations
- Production-ready monitoring and troubleshooting capabilities
Your subscription system can now handle:
- New subscriptions with secure checkout
- Plan changes without disrupting service
- Cancellations that maintain user goodwill
- Payment failures with appropriate grace periods
- Complex edge cases like cancel-during-switch scenarios
What's Next?
Consider these advanced features for your subscription system:
- Prorations for immediate plan changes
- Trial periods with automatic conversion
- Usage-based billing for metered features
- Team/multi-seat subscriptions
- Discount codes and promotions
- Advanced analytics and cohort analysis
You've built something amazing. Now go launch it and start growing your subscription business!
Want to read more articles?