Web Development

Stripe Subscription Setup with Supabase

February 22, 2026
18 min read

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:

  1. Stripe Dashboard: Products, prices, and webhook configuration
  2. Supabase Database: Subscription state and user data
  3. Edge Functions: Secure API endpoints for subscription operations
  4. 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

  1. Navigate to Stripe Dashboard → Products
  2. Click "+ Add product"
  3. Fill in your product details:
    • Name: Premium Subscription (or your product name)
    • Description: A brief description of what the subscription includes

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

  1. Go to Stripe Dashboard → Webhooks
  2. Click "+ Add endpoint"
  3. Set your Endpoint URL:
    https://your-project-ref.supabase.co/functions/v1/stripe-webhook
    
    Replace your-project-ref with 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 created
  • customer.subscription.created - Subscription initiated
  • customer.subscription.updated - Plan changes, renewals, cancellations
  • customer.subscription.deleted - Subscription permanently ended

Payment Events:

  • invoice.paid - Successful payment
  • invoice.payment_succeeded - Payment confirmed
  • invoice.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.

What we're doing: Setting up Stripe's customer portal with the right permissions.

  1. Navigate to Dashboard → Settings → Billing → Customer portal

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

  1. Go to Dashboard → Developers → API Keys

Copy These Keys

  • Secret Key (sk_test_...) → Save as STRIPE_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 NamePurposeFormat
STRIPE_SECRET_KEYServer-side Stripe API accesssk_test_...
STRIPE_WEBHOOK_SECRETWebhook signature verificationwhsec_...
STRIPE_PRICE_ID_MONTHLYMonthly subscription priceprice_...
STRIPE_PRICE_ID_YEARLYYearly subscription priceprice_...

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

ColumnTypePurposeExample Values
stripe_customer_idTEXTLinks user to Stripe customercus_ABC123...
stripe_subscription_idTEXTCurrent active subscriptionsub_XYZ789...
subscription_statusTEXTCurrent subscription stateactive, trialing, past_due, canceled
subscription_current_period_endTIMESTAMPTZWhen current billing period ends2024-03-15 10:30:00+00
subscription_cancel_at_period_endBOOLEANIs cancellation scheduled?true or false
subscription_enabledBOOLEANApp-level feature access controltrue or false
subscription_intervalTEXTCurrent billing frequencymonth or year
pending_switch_intervalTEXTScheduled plan changemonth, year, or null
pending_switch_effective_atTIMESTAMPTZWhen plan switch takes effect2024-03-15 10:30:00+00

Data Flow and State Management

Understanding the Column Relationships

Basic Subscription State:

  • subscription_status + subscription_enabled = User access level
  • subscription_current_period_end = When next billing occurs
  • subscription_interval = Current plan (monthly/yearly)

Cancellation Handling:

  • subscription_cancel_at_period_end = true means "cancel when period ends"
  • User keeps access until subscription_current_period_end

Plan Switching:

  • pending_switch_interval = Plan user wants to switch to
  • pending_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 LabelDatabase ValueStripe 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 interval parameter
  • 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:

OperationWhat It DoesWhen It Takes EffectUser Impact
CancelSchedule subscription to endAt period endKeeps access until billing period ends
ResumeUncancel a canceled subscriptionImmediatelyContinues billing normally
Switch PlanChange between monthly/yearlyAt next billing datePrice 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 EventWhat Triggers ItWhat We Do
checkout.session.completedUser completes paymentCreate subscription record
customer.subscription.updatedPlan changes, renewals, cancellationsSync subscription state
customer.subscription.deletedSubscription permanently endsDisable user access
invoice.payment_succeededSuccessful paymentLog payment, ensure access
invoice.payment_failedFailed paymentMark 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_interval to display the correct amount:
    • month → monthly price
    • year → yearly price

Canceling Badge and Alerts

  • Use subscription_cancel_at_period_end
  • Use subscription_current_period_end for "Access until" date

Pending Switch Banner

  • Use pending_switch_interval and pending_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

  1. Read from your DB, not Stripe: App logic should use user_profiles; Stripe is the source of truth only via webhooks.
  2. Handle webhook idempotency: Stripe may retry webhooks. Make your handlers idempotent where possible.
  3. Log webhook failures: Monitor Stripe webhook logs for delivery and processing errors.
  4. Keep portal limited: Disable cancel/switch in the portal if you manage these in-app.
  5. 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_profiles for 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:

  1. Check Stripe Dashboard → Webhooks → Recent deliveries
  2. Verify webhook URL is accessible: https://your-project.supabase.co/functions/v1/stripe-webhook
  3. Check Supabase Edge Function logs
  4. Verify STRIPE_WEBHOOK_SECRET is 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:

  1. Check if subscription schedule was created in Stripe
  2. Verify pending_switch_* fields are set in database
  3. 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!

Tags:
Stripe
Supabase
Subscriptions
Payments
Billing
Edge Functions
Webhooks

Want to read more articles?