Subscription Flow
This guide documents the complete subscription flow from checkout initiation through webhook processing to profile updates.
Overview
The subscription system uses Stripe Checkout for payment processing with webhooks for subscription lifecycle management. User profiles are updated via the admin client to bypass RLS.
Key Components
| Component | Location | Purpose |
|---|---|---|
| Checkout API | src/api/routes/stripe/checkout.ts | Creates Stripe checkout sessions |
| Portal API | src/api/routes/stripe/portal.ts | Customer billing portal access |
| Change Plan API | src/api/routes/stripe/change-plan.ts | Upgrade, downgrade, cancel, reactivate |
| Preview Change API | src/api/routes/stripe/preview-change.ts | Proration preview (read-only) |
| Webhook Handler | src/api/routes/webhooks/stripe.ts | Processes subscription events |
| Stripe Sync Utility | src/api/utils/stripe-sync.ts | Subscription resolution + billing sync |
| Subscription UI | src/lib/components/subscription/ | Card, Dialog, Banner, Warning components |
| Subscription Utils | src/lib/utils/subscription.ts | Tier limits, slot counting |
| Admin Client | src/lib/supabase/admin.ts | RLS bypass for webhooks |
| Integration Tests | src/api/routes/__tests__/subscription-flow.integration.test.ts | End-to-end test coverage |
Related Guide
For in-app plan management (upgrade, downgrade, cancel, reactivate), see the Subscription Lifecycle Management guide.
Flow Details
1. Checkout Initiation
The pricing page initiates checkout by calling the API:
// src/routes/pricing/+page.svelte
const response = await fetch('/api/stripe/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier: 'starter', // or 'professional'
userId: data.userId, // Supabase user ID
email: data.userEmail, // For Stripe receipt
customerId: data.stripeCustomerId // Optional: existing customer
})
});
const { url } = await response.json();
window.location.href = url; // Redirect to Stripe2. Session Metadata
The checkout endpoint embeds user identification in session metadata:
// src/api/routes/stripe/checkout.ts
const sessionConfig = {
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
metadata: {
user_id: userId, // Primary identifier for webhook
tier: tier // Subscription tier
},
subscription_data: {
metadata: {
user_id: userId,
tier: tier
}
}
};Metadata Placement
The user_id and tier are set in both metadata and subscription_data.metadata. This ensures the values are available in both checkout.session.completed and customer.subscription.* events.
3. Webhook Processing
When Stripe sends a webhook, the handler:
- Verifies signature - Rejects tampered requests
- Extracts user identity - From metadata or email fallback
- Determines tier - From metadata or subscription lookup
- Updates profile - Via admin client (bypasses RLS)
4. User Identification Strategies
The webhook handler uses multiple strategies to find the user:
| Priority | Strategy | Used When |
|---|---|---|
| 1 | session.metadata.user_id | Standard checkout (our endpoint) |
| 2 | Email lookup (lowercase) | Payment Links, external checkouts |
| 3 | stripe_customer_id | Subscription update/delete events |
// Primary: metadata from our checkout endpoint
const userId = session.metadata?.user_id;
// Fallback: email from Stripe session
const customerEmail = session.customer_email || session.customer_details?.email;
if (userId) {
await supabase.from('user_profiles').update({...}).eq('id', userId);
} else if (customerEmail) {
const normalizedEmail = customerEmail.toLowerCase();
await supabase.from('user_profiles').update({...}).eq('email', normalizedEmail);
}5. Tier Detection
Tier is determined through cascading strategies:
// 1. Check session metadata (from our checkout endpoint)
let tier = session.metadata?.tier;
// 2. If not in metadata, lookup subscription
if (subscriptionId && !tier) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0]?.price.id;
tier = getTierFromPriceId(priceId);
}
// 3. getTierFromPriceId() uses pattern matching as final fallbackgetTierFromPriceId() Implementation
// src/lib/supabase/admin.ts
export function getTierFromPriceId(priceId: string) {
// Check static map first
if (STRIPE_PRICE_TO_TIER[priceId]) {
return STRIPE_PRICE_TO_TIER[priceId];
}
// Pattern matching fallback
const lower = priceId.toLowerCase();
if (lower.includes('professional') || lower.includes('pro')) return 'professional';
if (lower.includes('enterprise')) return 'enterprise';
if (lower.includes('starter')) return 'starter';
return 'starter'; // Default
}Promotion Codes
Coupons and promotion codes are created in the Stripe dashboard. Nothing about a code lives in this repo, and issuing a new one needs no deploy. The app's part is three invariants.
1. The field is opt-in
Stripe hides the "Add promotion code" box unless the session asks for it. createCheckoutSession sets allow_promotion_codes: true once, on the shared sessionConfig in src/lib/billing/checkout-session.ts, so all three shapes (standard subscription, Founders, one-time add-on) inherit it. Both entry points (POST /api/stripe/checkout and the /checkout/start resume loader) build through that one function.
Mutually exclusive with discounts
Stripe rejects a session carrying both allow_promotion_codes and discounts. If a pre-applied-code lane is ever added (a ?promo= link), it must replace the flag on that session, not sit alongside it.
2. Settlement is tested with isCheckoutSettled, not === 'paid'
Every gate uses the shared isCheckoutSettled predicate in src/lib/billing/subscription-state.ts, which accepts paid and no_payment_required.
Measured, not assumed
Verified on staging in test mode (2026-08-13, API 2025-12-15.clover): a 100%-off promotion code does not yield no_payment_required on either shape we build. A discounted add-on (mode:'payment', amount_total 0, no PaymentIntent) and a discounted subscription both report payment_status: 'paid'.
Accepting the zero-due status is therefore defensive, covering the documented case where a session collects no payment method at all. Do not describe it as a fix for an observed failure.
The branch that would suffer most if no_payment_required ever did arrive is the add-on one: mode:'payment' emits no subscription events, so its credit grant has no fallback path, whereas a subscription re-grants via customer.subscription.updated.
The revenue guards are deliberately not relaxed: recordRevenueEvent stays gated on a positive amount_total / amount_paid. Confirmed in test mode: a $0 add-on and a $0 subscription each provisioned with zero revenue rows, while a 10%-discounted Production House purchase recorded $134.10, the discounted amount rather than the $149 list price.
3. Schedule phases must re-state the discount
A subscription schedule phase that omits discounts inherits only from the customer, never from the subscription, and a code redeemed at Checkout creates a subscription discount. updateSchedulePhases (src/api/utils/subscription-schedule.ts) therefore reads the schedule's current phases[0].discounts and re-states them on every phase it writes. Without that, a repeating or forever coupon is deleted the moment its holder schedules a downgrade.
It takes the schedule object rather than an id specifically so the discount source is always in hand. When there is no discount the key is omitted entirely, leaving the request identical to its pre-promotion-codes form.
Reusing the existing discount id (rather than re-deriving from coupon) is what preserves a repeating coupon's remaining months; re-deriving would mint a fresh discount and restart the clock.
Preview surfaces
POST /api/stripe/preview-change returns newMonthlyRate from the target price's raw unit_amount, so it is unambiguously a list price. It also returns a discounts descriptor and PlanChangeDialog labels that rate with it.
Three constraints on the descriptor:
- It is attached only to the recurring rate.
prorationAmountcomes from a Stripe preview invoice rather than a price lookup, so annotating it "before your discount" would promise a second reduction on a figure that may already be net. - Coupons carrying
applies_to.productsare filtered against the target price's product. A coupon scoped to one plan does not discount another, and naming it there would promise a reduction that never arrives. Fails open when the target product cannot be resolved: hiding a real discount is worse than naming one the customer already knows they hold. - It comes from a fail-soft expanded re-retrieve that runs only when the subscription carries a discount, so an undiscounted customer pays no extra Stripe call and an expansion failure degrades the copy instead of breaking the preview.
The discount is not folded into the rate on purpose: one "discounted rate" number is wrong for a once coupon and goes stale for a repeating one.
Founders
The Founders session is two line items, a $0/mo recurring price plus the $399 one-time fee, with the $0 to $49 schedule attached in the webhook. Scope a Founders coupon to the one-time product via applies_to, or give it duration: once, so it discounts the fee rather than persisting onto the $49 phase.
Database Updates
Fields Updated by Webhook
| Event | Fields Updated |
|---|---|
checkout.session.completed | subscription_tier, subscription_status, stripe_customer_id, stripe_subscription_id, subscription_current_period_end, updated_at |
customer.subscription.created/updated | subscription_tier, subscription_status, subscription_cancel_at_period_end, subscription_current_period_end, updated_at |
customer.subscription.deleted | subscription_tier → 'free', subscription_status → 'canceled', subscription_cancel_at_period_end → false, updated_at |
invoice.payment_failed | subscription_status → 'past_due', updated_at |
Fields Updated by Plan Change Endpoints
In-app plan changes write to the database immediately (don't rely solely on webhooks):
| Action | Fields Written |
|---|---|
| Upgrade/Downgrade | subscription_tier, updated_at |
| Cancel to Free | subscription_cancel_at_period_end → true, updated_at |
| Reactivate | subscription_cancel_at_period_end → false, updated_at |
Status Mapping
| Stripe Status | App Status |
|---|---|
active | active |
trialing | trialing |
past_due | past_due |
canceled | canceled |
unpaid | canceled |
| Other | active |
Payment Link Flow
For users who subscribe without being logged in (via Stripe Payment Links):
Security Considerations
Webhook Signature Verification
All webhooks must pass signature verification:
const signature = c.req.header('stripe-signature');
if (!signature) {
return c.json({ error: 'Missing stripe-signature header' }, 400);
}
try {
const body = await c.req.text(); // Must use raw text!
event = stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET);
} catch (err) {
return c.json({ error: `Webhook Error: ${err.message}` }, 400);
}Critical
Always use c.req.text() (raw body) for signature verification. Using c.req.json() will cause verification to fail.
Admin Client for RLS Bypass
Webhooks use the admin client because:
- No user session exists in webhook context
- Need to update profiles for users who may not exist yet
- Must bypass RLS policies
const SUPABASE_ADMIN_KEY = c.env?.SUPABASE_SECRET_KEY || c.env?.SUPABASE_SERVICE_ROLE_KEY;
const supabase = createAdminClient(PUBLIC_SUPABASE_URL, SUPABASE_ADMIN_KEY);Error Handling
Webhook handlers return 200 even on database errors to prevent Stripe retries:
if (updateError) {
console.error('Failed to update user profile:', updateError);
// Don't throw - return 200 to prevent infinite retries
}
return c.json({ received: true });Integration Test Coverage
The subscription flow has comprehensive integration tests in src/api/routes/__tests__/subscription-flow.integration.test.ts:
Test Categories
| Category | Tests |
|---|---|
| Complete Flow | Starter/Professional checkout → webhook → profile update |
| Email Fallback | Payment Links without user_id, email normalization |
| Tier Detection | Metadata priority, subscription lookup, pattern matching |
| Lifecycle Events | Upgrade, cancellation, payment failure |
| Security | Signature verification, missing headers |
| Edge Cases | No subscription, missing metadata, database failures |
Running Integration Tests
# Run subscription flow tests
pnpm test src/api/routes/__tests__/subscription-flow.integration.test.ts
# Run with coverage
pnpm test -- --coverageBehavior Assertions
Tests use behavior assertions that verify actual data written to the database:
// Capture actual database writes
const profileUpdate = capturedProfileUpdates[0];
// Verify exact data written (not just method calls)
expect(profileUpdate.data).toEqual(
expect.objectContaining({
subscription_tier: 'starter',
subscription_status: 'active',
stripe_customer_id: 'cus_test_123'
})
);Environment Variables
Required for Checkout
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Stripe API authentication |
STRIPE_STARTER_PRICE_ID | Price ID for starter tier |
STRIPE_PROFESSIONAL_PRICE_ID | Price ID for professional tier |
PUBLIC_APP_URL | Base URL for success/cancel redirects |
Required for Webhooks
| Variable | Purpose |
|---|---|
STRIPE_SECRET_KEY | Stripe API authentication |
STRIPE_WEBHOOK_SECRET | Webhook signature verification |
PUBLIC_SUPABASE_URL | Supabase project URL |
SUPABASE_SECRET_KEY | Admin access (or legacy SUPABASE_SERVICE_ROLE_KEY) |
Setting Secrets
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_IDRelated Documentation
- Subscription Lifecycle Management - In-app plan changes, billing sync, UI components
- Stripe API Routes - All Stripe endpoints (checkout, portal, change-plan, preview, reactivate)
- Webhooks API - Webhook handler details
- Stripe Integration - Full Stripe documentation
- Supabase Admin - Admin client patterns