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:
{
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:
{
url: string; // Stripe Checkout URL to redirect to
sessionId: string; // Checkout session ID
}Usage in UI:
// 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:
{
customerId: string; // Stripe customer ID (required)
returnUrl?: string; // URL to return after portal (default: /settings)
}Response:
{
url: string; // Stripe Portal URL to redirect to
}Usage:
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:
| Event | Action |
|---|---|
checkout.session.completed | Links subscription to user, sets tier, status, stripe_subscription_id |
customer.subscription.created | Updates tier, status, lifecycle fields by customer ID |
customer.subscription.updated | Syncs status, cancel_at_period_end, current_period_end |
customer.subscription.deleted | Downgrades to free tier, clears lifecycle fields |
invoice.payment_succeeded | No action (logged only) |
invoice.payment_failed | Sets status to past_due |
Webhook Verification:
const signature = c.req.header('stripe-signature');
const event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);Checkout Flow
Standard Flow (Logged-in Users)
Payment Link Flow (Unauthenticated Users)
For users who purchase before creating an account:
- User clicks Stripe Payment Link (no auth required)
- Stripe redirects to
/checkout/success?session_id=xxx - Success page retrieves session from Stripe
- If customer email matches existing user: auto-link subscription
- Otherwise: show signup form with Stripe customer ID in metadata
Success Page Logic (src/routes/checkout/success/+page.server.ts):
// 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
-- 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=expiresBilling 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
CREATE TYPE subscription_tier AS ENUM (
'free',
'starter',
'professional',
'enterprise'
);Status Values
| Status | Description |
|---|---|
active | Subscription is active and paid |
trialing | In trial period |
past_due | Payment failed, grace period |
canceled | Subscription ended |
incomplete | Initial payment incomplete |
inactive | No subscription (default) |
Podcast Limits by Tier
| Tier | Max Podcasts | Source |
|---|---|---|
free | 1 | src/lib/utils/subscription.ts |
starter | 3 | |
professional | 5 | |
enterprise | Unlimited |
Indexes
-- 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
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
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)
- Session metadata - Set by our checkout endpoint:
session.metadata.tier - Line item product name - For Payment Links:
product.name - Subscription items - Fallback for subscription events
- Price ID pattern matching - Last resort fallback
Environment Variables
Public (Browser-Safe)
# 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)
# 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
# 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_IDStripe Client
Server-Side Client
Location: src/lib/stripe/client.ts
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:
// 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
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
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.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
Local Testing
# 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/stripeSecurity Considerations
Webhook Signature Verification
Always verify webhook signatures:
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:
const supabase = createAdminClient(PUBLIC_SUPABASE_URL, SUPABASE_ADMIN_KEY);Email Case Sensitivity
Email matching uses lowercase normalization:
const normalizedEmail = customerEmail.toLowerCase();
await supabase
.from('user_profiles')
.update({ ... })
.eq('email', normalizedEmail);File Inventory
| Path | Purpose |
|---|---|
src/lib/stripe/client.ts | Server-side Stripe client |
src/lib/supabase/admin.ts | Admin client + tier mapping |
src/lib/utils/subscription.ts | Tier limits, getPodcastLimit(), countOccupiedSlots() |
src/api/routes/stripe/checkout.ts | Checkout session creation |
src/api/routes/stripe/portal.ts | Customer portal session |
src/api/routes/stripe/change-plan.ts | Plan changes + reactivation |
src/api/routes/stripe/preview-change.ts | Proration preview (read-only) |
src/api/routes/webhooks/stripe.ts | Webhook event handler |
src/api/utils/stripe-sync.ts | Subscription 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.svelte | Settings 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:
// 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.completedevent:- 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/updatedevents:- Update by Stripe customer ID
- Status mapping (active, past_due, canceled, trialing)
- Verifies correct status values written
customer.subscription.deletedevent:- Downgrade to free tier (verifies
subscription_tier: 'free',subscription_status: 'canceled')
- Downgrade to free tier (verifies
invoice.payment_failedevent:- Set past_due status (verifies
subscription_status: 'past_due')
- Set past_due status (verifies
invoice.payment_succeededevent:- 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 lookupsubscription_status— mapped from Stripe statusstripe_customer_id— set oncheckout.session.completedstripe_subscription_id— cached for future API operationssubscription_cancel_at_period_end— synced fromsubscription.updatedeventssubscription_current_period_end— billing period end dateupdated_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_TIERmap:- Export availability
- Runtime additions support
Running Tests
# 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:watchIntegration 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/:
| Component | Purpose |
|---|---|
SubscriptionCard.svelte | Main settings card showing tier, status, billing period, and plan change buttons |
PlanChangeDialog.svelte | Multi-step dialog: loading → proration preview → confirm → success/error |
SubscriptionStatusBanner.svelte | Top-of-page banner for cancel_at_period_end (amber) or past_due (red) states |
DowngradeBlockedDialog.svelte | Shows which podcasts must be deleted before downgrading |
BillingWarning.svelte | Alert 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-changeto 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:
- DB cache hit: Returns
stripe_subscription_idfromuser_profiles - Stripe lookup: Queries
stripe.subscriptions.list()bystripe_customer_id, caches result in DB - No subscription: Returns
null
Used by change-plan, preview-change, and reactivate endpoints.
Billing Sync Functions
| Function | Trigger | Stripe Action |
|---|---|---|
cancelSubscriptionAtPeriodEnd() | User deletes last podcast | cancel_at_period_end: true |
uncancelSubscription() | User creates podcast while canceling | cancel_at_period_end: false |
pauseSubscriptionBilling() | User pauses last active podcast | pause_collection: { behavior: 'void' } |
resumeSubscriptionBilling() | User unpauses any podcast | Clear pause_collection |
All functions check shouldSyncToStripe() first, which skips enterprise, LTD, and users without subscriptions.
Related Documentation
- Subscription Flow Guide - End-to-end flow documentation
- Supabase Integration - Database and admin client patterns
- Architecture Overview - System architecture
- Stripe API Routes - Checkout and portal endpoints
- Webhooks API - Webhook handler details