Skip to content

Stripe Integration

This document details the Stripe integration for show.fm, including checkout flows, webhook handling, subscription management, and the database schema for billing.

Architecture Overview

show.fm uses Stripe for subscription billing with a direct integration pattern:

┌─────────────────────────────────────────────────────────────────────────┐
│                        USER FLOW                                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐         │
│  │   /pricing      │  │  /api/stripe/   │  │   Stripe        │         │
│  │   Page          │  │  checkout       │  │   Checkout      │         │
│  └────────┬────────┘  └────────┬────────┘  └────────┬────────┘         │
│           │                    │                    │                   │
│           │  1. Select Plan    │  2. Create Session │  3. Complete      │
│           │─────────────────▶ │ ─────────────────▶ │     Payment       │
│           │                    │                    │                   │
│           │                    │                    │  4. Redirect      │
│           │                    │                    │◀─────────────────│
│           │                    │                    │                   │
│  ┌────────▼────────┐           │           ┌────────▼────────┐         │
│  │ /checkout/      │           │           │  /api/webhooks/ │         │
│  │ success         │           │           │  stripe         │         │
│  └─────────────────┘           │           └────────┬────────┘         │
│                                │                    │                   │
│                                │                    │  5. Update DB     │
│                                │                    │─────────────────▶ │
│                                │                    │                   │
└────────────────────────────────┼────────────────────┼───────────────────┘
                                 │                    │
                                 ▼                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                         SUPABASE                                         │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                      user_profiles                                │   │
│  │  ┌──────────────────┬──────────────────┬────────────────────┐   │   │
│  │  │ stripe_customer  │ subscription_    │ subscription_      │   │   │
│  │  │ _id              │ tier             │ status             │   │   │
│  │  └──────────────────┴──────────────────┴────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

API Endpoints

POST /api/stripe/checkout

Creates a Stripe Checkout Session for subscription purchase.

Location: src/api/routes/stripe/checkout.ts

Request Body:

typescript
{
  tier: 'starter' | 'professional';
  userId: string;    // User ID from Supabase Auth
  email: string;     // User's email for receipt
  customerId?: string; // Existing Stripe customer ID (optional)
}

Response:

typescript
{
	url: string; // Stripe Checkout URL to redirect to
	sessionId: string; // Checkout session ID
}

Usage in UI:

typescript
// src/routes/pricing/+page.svelte
async function handleSubscribe(tier: 'starter' | 'professional') {
	const response = await fetch('/api/stripe/checkout', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({
			tier,
			userId: data.userId,
			email: data.userEmail,
			customerId: data.stripeCustomerId
		})
	});

	const { url } = await response.json();
	window.location.href = url; // Redirect to Stripe
}

POST /api/stripe/portal

Creates a Stripe Customer Portal session for subscription management.

Location: src/api/routes/stripe/portal.ts

Request Body:

typescript
{
  customerId: string;    // Stripe customer ID (required)
  returnUrl?: string;    // URL to return after portal (default: /settings)
}

Response:

typescript
{
	url: string; // Stripe Portal URL to redirect to
}

Usage:

typescript
async function manageSubscription(customerId: string) {
	const response = await fetch('/api/stripe/portal', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ customerId })
	});

	const { url } = await response.json();
	window.location.href = url;
}

POST /api/stripe/change-plan

Handles in-app upgrade, downgrade, and cancellation. See Stripe API Routes for full request/response specs.

Location: src/api/routes/stripe/change-plan.ts

POST /api/stripe/change-plan/reactivate

Reverses a pending cancellation. See Stripe API Routes for full specs.

Location: src/api/routes/stripe/change-plan.ts

POST /api/stripe/preview-change

Returns proration details for a proposed plan change (read-only). See Stripe API Routes for full specs.

Location: src/api/routes/stripe/preview-change.ts

POST /api/webhooks/stripe

Handles Stripe webhook events to sync subscription state.

Location: src/api/routes/webhooks/stripe.ts

Handled Events:

EventAction
checkout.session.completedLinks subscription to user, sets tier, status, stripe_subscription_id
customer.subscription.createdUpdates tier, status, lifecycle fields by customer ID
customer.subscription.updatedSyncs status, cancel_at_period_end, current_period_end
customer.subscription.deletedDowngrades to free tier, clears lifecycle fields
invoice.payment_succeededNo action (logged only)
invoice.payment_failedSets status to past_due

Webhook Verification:

typescript
const signature = c.req.header('stripe-signature');
const event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);

Checkout Flow

Standard Flow (Logged-in Users)

For users who purchase before creating an account:

  1. User clicks Stripe Payment Link (no auth required)
  2. Stripe redirects to /checkout/success?session_id=xxx
  3. Success page retrieves session from Stripe
  4. If customer email matches existing user: auto-link subscription
  5. Otherwise: show signup form with Stripe customer ID in metadata

Success Page Logic (src/routes/checkout/success/+page.server.ts):

typescript
// Priority 1: Match by session metadata (our checkout endpoint)
if (session.metadata?.user_id === user.id) {
	/* linked */
}

// Priority 2: Match by email (Payment Links)
if (user.email?.toLowerCase() === customerEmail?.toLowerCase()) {
	// Auto-link subscription to existing user
}

// Priority 3: No match - prompt signup/login
return { needsSignup: true, stripeCustomerId: customerId };

Database Schema

Subscription Fields in user_profiles

sql
-- Core billing identity
stripe_customer_id TEXT UNIQUE,                          -- Links to Stripe customer
stripe_subscription_id TEXT,                             -- Stripe subscription ID (cached for API operations)

-- Subscription state
subscription_tier subscription_tier DEFAULT 'free',      -- free, starter, professional, enterprise
subscription_status TEXT DEFAULT 'inactive',             -- active, trialing, past_due, canceled, inactive

-- Lifecycle management
subscription_cancel_at_period_end BOOLEAN DEFAULT false, -- True when set to cancel at period end
subscription_current_period_end TIMESTAMPTZ,             -- End of current billing period (for UI messaging)

-- Billing model guard
billing_model TEXT NOT NULL DEFAULT 'subscription',      -- subscription, ltd, enterprise_manual
ltd_expires_at TIMESTAMPTZ                               -- For LTD users: NULL=perpetual, date=expires

Billing Model Guard

The billing_model column determines which Stripe automation applies. All plan change and billing sync endpoints check this value first:

  • subscription — Standard Stripe recurring billing. All plan management features active.
  • ltd — Lifetime/3-year launch deal. Skip all Stripe subscription automation.
  • enterprise_manual — Sales-managed billing. Skip all Stripe automation.

Subscription Tier Enum

sql
CREATE TYPE subscription_tier AS ENUM (
  'free',
  'starter',
  'professional',
  'enterprise'
);

Status Values

StatusDescription
activeSubscription is active and paid
trialingIn trial period
past_duePayment failed, grace period
canceledSubscription ended
incompleteInitial payment incomplete
inactiveNo subscription (default)

Podcast Limits by Tier

TierMax PodcastsSource
free1src/lib/utils/subscription.ts
starter3
professional5
enterpriseUnlimited

Indexes

sql
-- Customer lookup (webhook handler)
CREATE INDEX idx_user_profiles_stripe ON user_profiles(stripe_customer_id);

-- Subscription lookup (plan changes, billing sync)
CREATE INDEX idx_user_profiles_stripe_sub ON user_profiles(stripe_subscription_id)
  WHERE stripe_subscription_id IS NOT NULL;

Billing Model Constraint

sql
ALTER TABLE user_profiles ADD CONSTRAINT user_profiles_billing_model_check
  CHECK (billing_model IN ('subscription', 'ltd', 'enterprise_manual'));

Price ID Resolution

When webhooks receive subscription events, the tier is determined through multiple strategies:

Location: src/lib/supabase/admin.ts

typescript
export function getTierFromPriceId(priceId: string): SubscriptionTier {
	// 1. Check static map (configured price IDs)
	if (STRIPE_PRICE_TO_TIER[priceId]) {
		return STRIPE_PRICE_TO_TIER[priceId];
	}

	// 2. Pattern matching on price ID name
	const lowerPriceId = priceId.toLowerCase();
	if (lowerPriceId.includes('professional')) return 'professional';
	if (lowerPriceId.includes('enterprise')) return 'enterprise';
	if (lowerPriceId.includes('starter')) return 'starter';

	// 3. Default fallback
	return 'starter';
}

Tier Detection Priority (in webhook handler)

  1. Session metadata - Set by our checkout endpoint: session.metadata.tier
  2. Line item product name - For Payment Links: product.name
  3. Subscription items - Fallback for subscription events
  4. Price ID pattern matching - Last resort fallback

Environment Variables

Public (Browser-Safe)

bash
# Stripe publishable key for client-side
PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# Payment Link URLs (fallback for unauthenticated users)
PUBLIC_STRIPE_STARTER_LINK=https://buy.stripe.com/...
PUBLIC_STRIPE_PROFESSIONAL_LINK=https://buy.stripe.com/...

Private (Server-Only via wrangler secret)

bash
# API authentication
STRIPE_SECRET_KEY=sk_test_...

# Webhook signature verification (primary endpoint; the optional
# STRIPE_WEBHOOK_SECRET_SHOWFM carries the my.show.fm endpoint's secret during
# the Epic 16 dual-host transition — see api/routes/webhooks.md)
STRIPE_WEBHOOK_SECRET=whsec_...

# Price IDs for checkout session creation
STRIPE_STARTER_PRICE_ID=price_...
STRIPE_PROFESSIONAL_PRICE_ID=price_...

Setting Secrets

bash
# Set Stripe secrets (never commit these!)
wrangler secret put STRIPE_SECRET_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET
wrangler secret put STRIPE_STARTER_PRICE_ID
wrangler secret put STRIPE_PROFESSIONAL_PRICE_ID

Stripe Client

Server-Side Client

Location: src/lib/stripe/client.ts

typescript
import Stripe from 'stripe';
import { STRIPE_SECRET_KEY } from '$env/static/private';

export const stripe = new Stripe(STRIPE_SECRET_KEY);

export async function createCheckoutSession({
	priceId,
	customerId,
	successUrl,
	cancelUrl,
	metadata
}) {
	return stripe.checkout.sessions.create({
		mode: 'subscription',
		payment_method_types: ['card'],
		line_items: [{ price: priceId, quantity: 1 }],
		customer: customerId,
		success_url: successUrl,
		cancel_url: cancelUrl,
		metadata
	});
}

export async function createPortalSession({ customerId, returnUrl }) {
	return stripe.billingPortal.sessions.create({
		customer: customerId,
		return_url: returnUrl
	});
}

Hono Route Pattern

API routes create their own Stripe instance from environment:

typescript
// src/api/routes/stripe/checkout.ts
stripeCheckout.post('/', async (c) => {
	const STRIPE_SECRET_KEY = c.env?.STRIPE_SECRET_KEY;
	const stripe = new Stripe(STRIPE_SECRET_KEY);
	// ... create session
});

Subscription Data in App Layout

The app layout loads subscription info for all authenticated pages, including lifecycle fields for UI components like the status banner and subscription card:

Location: src/routes/(app)/+layout.server.ts

typescript
export const load: LayoutServerLoad = async ({ locals }) => {
	const {
		data: { user }
	} = await locals.supabase.auth.getUser();
	if (!user) return { user: null, subscription: null };

	const { data: profile } = await locals.supabase
		.from('user_profiles')
		.select(
			`
      subscription_tier, subscription_status, stripe_customer_id,
      subscription_cancel_at_period_end, subscription_current_period_end,
      billing_model
    `
		)
		.eq('id', user.id)
		.single();

	return {
		user,
		subscription: {
			tier: profile?.subscription_tier ?? 'free',
			status: profile?.subscription_status ?? 'inactive',
			stripeCustomerId: profile?.stripe_customer_id ?? null,
			cancelAtPeriodEnd: profile?.subscription_cancel_at_period_end ?? false,
			currentPeriodEnd: profile?.subscription_current_period_end ?? null,
			billingModel: profile?.billing_model ?? 'subscription'
		}
	};
};

User Profile Trigger

When users sign up, their subscription info from Stripe is applied automatically:

Location: supabase/migrations/20251231124637_update_handle_new_user_trigger.sql

sql
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.user_profiles (
    id, email, full_name,
    subscription_tier, subscription_status, stripe_customer_id
  ) VALUES (
    NEW.id,
    NEW.email,
    COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
    COALESCE(
      (NEW.raw_user_meta_data->>'subscription_tier')::subscription_tier,
      'free'
    ),
    CASE
      WHEN NEW.raw_user_meta_data->>'stripe_customer_id' IS NOT NULL
      THEN 'active'
      ELSE 'inactive'
    END,
    NEW.raw_user_meta_data->>'stripe_customer_id'
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

Webhook Configuration

Stripe Dashboard Settings

Configure webhook endpoint in Stripe Dashboard:

Endpoint URL: https://app.podcasterplus.com/api/webhooks/stripe

Events to Listen For:

  • checkout.session.completed
  • customer.subscription.created
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.payment_succeeded
  • invoice.payment_failed

Local Testing

bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login to Stripe
stripe login

# Forward webhooks to local dev server
stripe listen --forward-to localhost:5173/api/webhooks/stripe

Security Considerations

Webhook Signature Verification

Always verify webhook signatures:

typescript
try {
	const event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err) {
	return c.json({ error: 'Invalid signature' }, 400);
}

Admin Client for DB Updates

Webhooks use the admin client to bypass RLS:

typescript
const supabase = createAdminClient(PUBLIC_SUPABASE_URL, SUPABASE_ADMIN_KEY);

Email Case Sensitivity

Email matching uses lowercase normalization:

typescript
const normalizedEmail = customerEmail.toLowerCase();
await supabase
  .from('user_profiles')
  .update({ ... })
  .eq('email', normalizedEmail);

File Inventory

PathPurpose
src/lib/stripe/client.tsServer-side Stripe client
src/lib/supabase/admin.tsAdmin client + tier mapping
src/lib/utils/subscription.tsTier limits, getPodcastLimit(), countOccupiedSlots()
src/api/routes/stripe/checkout.tsCheckout session creation
src/api/routes/stripe/portal.tsCustomer portal session
src/api/routes/stripe/change-plan.tsPlan changes + reactivation
src/api/routes/stripe/preview-change.tsProration preview (read-only)
src/api/routes/webhooks/stripe.tsWebhook event handler
src/api/utils/stripe-sync.tsSubscription resolution + billing sync
src/lib/components/subscription/UI components (Card, Dialog, Banner, Warning)
src/routes/pricing/Pricing page and server load
src/routes/checkout/success/Post-checkout flow
src/routes/(app)/settings/+page.svelteSettings page (hosts SubscriptionCard)

Test Coverage

The Stripe integration has comprehensive test coverage across all modules using behavior assertions that verify what data is actually written, not just that methods were called.

API Route Tests

src/api/routes/stripe/__tests__/checkout.test.ts:

  • Configuration validation (missing STRIPE_SECRET_KEY, PUBLIC_APP_URL)
  • Request validation (missing tier, userId, email)
  • Tier-to-price-ID resolution (starter, professional)
  • Price ID not configured error
  • Existing customer handling (customerId parameter)
  • New customer email handling
  • Stripe API error handling
  • Invalid JSON body handling
  • Metadata inclusion for webhook reconciliation

src/api/routes/stripe/__tests__/portal.test.ts:

  • Configuration validation (missing STRIPE_SECRET_KEY)
  • Customer ID requirement validation
  • Custom return URL handling
  • Default return URL fallback (/settings)
  • Empty returnUrl string handling
  • Stripe API error handling (invalid customer, rate limiting)
  • Invalid JSON body handling
  • HTTP method validation (POST only)
  • Internal error message masking (security)

Webhook Handler Tests

src/api/routes/webhooks/__tests__/stripe.test.ts:

Tests use behavior assertions with mock argument capture to verify actual database updates:

typescript
// Mock setup captures what data was written
const { mockSupabaseUpdate, mockSupabaseFrom } = vi.hoisted(() => ({
  mockSupabaseUpdate: vi.fn(),
  mockSupabaseFrom: vi.fn()
}));

vi.mock('$lib/supabase/admin', () => ({
  createAdminClient: vi.fn(() => ({
    from: mockSupabaseFrom.mockImplementation((table) => ({
      update: mockSupabaseUpdate.mockImplementation((data) => ({
        eq: vi.fn().mockImplementation(() => ({
          select: vi.fn().mockResolvedValue({ data: [...], error: null })
        }))
      }))
    }))
  }))
}));

// Assertions verify actual data written
expect(mockSupabaseFrom).toHaveBeenCalledWith('user_profiles');
expect(mockSupabaseUpdate).toHaveBeenCalledWith(
  expect.objectContaining({
    subscription_tier: 'starter',
    subscription_status: 'active',
    stripe_customer_id: 'cus_test_123'
  })
);

Test Coverage Areas:

  • Configuration validation (all required env vars)
  • Signature verification (missing header, invalid signature)
  • checkout.session.completed event:
    • User ID from metadata (verifies tier, status, customer_id written)
    • Email fallback lookup
    • Tier from metadata vs subscription lookup
    • Professional tier from metadata
    • Missing user_id and email handling
  • customer.subscription.created/updated events:
    • Update by Stripe customer ID
    • Status mapping (active, past_due, canceled, trialing)
    • Verifies correct status values written
  • customer.subscription.deleted event:
    • Downgrade to free tier (verifies subscription_tier: 'free', subscription_status: 'canceled')
  • invoice.payment_failed event:
    • Set past_due status (verifies subscription_status: 'past_due')
  • invoice.payment_succeeded event:
    • Acknowledge without action
  • Unhandled event types (log and acknowledge)
  • Database error handling (returns 200 to prevent retries)
  • Signature verification before processing

Webhook Database Updates

The webhook handler syncs the following fields from Stripe events:

  • subscription_tier — resolved from metadata or price ID lookup
  • subscription_status — mapped from Stripe status
  • stripe_customer_id — set on checkout.session.completed
  • stripe_subscription_id — cached for future API operations
  • subscription_cancel_at_period_end — synced from subscription.updated events
  • subscription_current_period_end — billing period end date
  • updated_at

Client Library Tests

src/lib/stripe/__tests__/client.test.ts:

  • createCheckoutSession():
    • Correct Stripe API parameters
    • Customer ID inclusion when provided
    • Stripe API error propagation
  • createPortalSession():
    • Correct Stripe API parameters
    • Missing customer error handling

Supabase Admin Tests

src/lib/supabase/__tests__/admin.test.ts:

  • createAdminClient():
    • Correct Supabase configuration
    • Legacy service_role key support
  • getTierFromPriceId():
    • Unknown price IDs → 'starter'
    • Starter tier detection
    • Professional tier detection (including 'pro')
    • Enterprise tier detection
    • Case-insensitive matching
    • Edge cases (empty strings, partial matches)
  • STRIPE_PRICE_TO_TIER map:
    • Export availability
    • Runtime additions support

Running Tests

bash
# Run all Stripe-related tests
pnpm test src/api/routes/stripe
pnpm test src/api/routes/webhooks
pnpm test src/lib/stripe
pnpm test src/lib/supabase/__tests__/admin

# Run with coverage
pnpm test -- --coverage

# Watch mode
pnpm test:watch

Integration Testing

The subscription flow has comprehensive integration tests that verify the complete checkout → webhook → profile update pipeline.

Location: src/api/routes/__tests__/subscription-flow.integration.test.ts

Test Coverage:

  • Complete flow (starter and professional tiers)
  • Email fallback for Payment Links
  • Tier detection from metadata vs subscription
  • Subscription lifecycle (upgrade, cancellation, payment failure)
  • Webhook signature verification
  • Edge cases (missing metadata, database failures)

See the Subscription Flow Guide for detailed test documentation.

Subscription Management UI

The subscription management UI is composed of Svelte 5 components in src/lib/components/subscription/:

ComponentPurpose
SubscriptionCard.svelteMain settings card showing tier, status, billing period, and plan change buttons
PlanChangeDialog.svelteMulti-step dialog: loading → proration preview → confirm → success/error
SubscriptionStatusBanner.svelteTop-of-page banner for cancel_at_period_end (amber) or past_due (red) states
DowngradeBlockedDialog.svelteShows which podcasts must be deleted before downgrading
BillingWarning.svelteAlert shown when deleting/pausing the last podcast on a paid plan

SubscriptionCard

Displayed on the Settings page. Shows:

  • Current tier with status badge (active, canceling, past_due)
  • Billing period end date ("Renews on..." or "Ends on...")
  • Action buttons: Upgrade, Downgrade, Manage Billing (portal)
  • Billing model guard: LTD/enterprise users see "Contact support"

PlanChangeDialog

Multi-step dialog flow:

loading → preview → confirm → processing → success
                  ↘ blocked (409: too many podcasts)
                  ↘ error (402: card declined)
  • Preview step: Calls /api/stripe/preview-change to show proration amounts
  • Blocked step: Shows excess podcasts with links to delete them, or force-delete option
  • Error step: Card decline shows "Update payment method" button (links to Stripe portal)

SubscriptionStatusBanner

Rendered in the app layout (src/routes/(app)/+layout.svelte) for two states:

  • Pending cancellation: Amber banner with "Reactivate" button
  • Past due: Red banner with "Update Payment" button (opens Stripe portal)

Stripe Sync Utility

Location: src/api/utils/stripe-sync.ts

Centralizes Stripe API calls triggered by podcast lifecycle events. All functions are safe to call as side effects — they try-catch internally, log failures, and never throw.

resolveStripeSubscription()

Resolves the Stripe subscription ID for a user. Handles three scenarios:

  1. DB cache hit: Returns stripe_subscription_id from user_profiles
  2. Stripe lookup: Queries stripe.subscriptions.list() by stripe_customer_id, caches result in DB
  3. No subscription: Returns null

Used by change-plan, preview-change, and reactivate endpoints.

Billing Sync Functions

FunctionTriggerStripe Action
cancelSubscriptionAtPeriodEnd()User deletes last podcastcancel_at_period_end: true
uncancelSubscription()User creates podcast while cancelingcancel_at_period_end: false
pauseSubscriptionBilling()User pauses last active podcastpause_collection: { behavior: 'void' }
resumeSubscriptionBilling()User unpauses any podcastClear pause_collection

All functions check shouldSyncToStripe() first, which skips enterprise, LTD, and users without subscriptions.

Internal documentation - Not for public distribution