Skip to content

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

ComponentLocationPurpose
Checkout APIsrc/api/routes/stripe/checkout.tsCreates Stripe checkout sessions
Portal APIsrc/api/routes/stripe/portal.tsCustomer billing portal access
Change Plan APIsrc/api/routes/stripe/change-plan.tsUpgrade, downgrade, cancel, reactivate
Preview Change APIsrc/api/routes/stripe/preview-change.tsProration preview (read-only)
Webhook Handlersrc/api/routes/webhooks/stripe.tsProcesses subscription events
Stripe Sync Utilitysrc/api/utils/stripe-sync.tsSubscription resolution + billing sync
Subscription UIsrc/lib/components/subscription/Card, Dialog, Banner, Warning components
Subscription Utilssrc/lib/utils/subscription.tsTier limits, slot counting
Admin Clientsrc/lib/supabase/admin.tsRLS bypass for webhooks
Integration Testssrc/api/routes/__tests__/subscription-flow.integration.test.tsEnd-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:

typescript
// 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 Stripe

2. Session Metadata

The checkout endpoint embeds user identification in session metadata:

typescript
// 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:

  1. Verifies signature - Rejects tampered requests
  2. Extracts user identity - From metadata or email fallback
  3. Determines tier - From metadata or subscription lookup
  4. Updates profile - Via admin client (bypasses RLS)

4. User Identification Strategies

The webhook handler uses multiple strategies to find the user:

PriorityStrategyUsed When
1session.metadata.user_idStandard checkout (our endpoint)
2Email lookup (lowercase)Payment Links, external checkouts
3stripe_customer_idSubscription update/delete events
typescript
// 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:

typescript
// 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 fallback
getTierFromPriceId() Implementation
typescript
// 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. prorationAmount comes 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.products are 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

EventFields Updated
checkout.session.completedsubscription_tier, subscription_status, stripe_customer_id, stripe_subscription_id, subscription_current_period_end, updated_at
customer.subscription.created/updatedsubscription_tier, subscription_status, subscription_cancel_at_period_end, subscription_current_period_end, updated_at
customer.subscription.deletedsubscription_tier → 'free', subscription_status → 'canceled', subscription_cancel_at_period_end → false, updated_at
invoice.payment_failedsubscription_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):

ActionFields Written
Upgrade/Downgradesubscription_tier, updated_at
Cancel to Freesubscription_cancel_at_period_end → true, updated_at
Reactivatesubscription_cancel_at_period_end → false, updated_at

Status Mapping

Stripe StatusApp Status
activeactive
trialingtrialing
past_duepast_due
canceledcanceled
unpaidcanceled
Otheractive

For users who subscribe without being logged in (via Stripe Payment Links):

Security Considerations

Webhook Signature Verification

All webhooks must pass signature verification:

typescript
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:

  1. No user session exists in webhook context
  2. Need to update profiles for users who may not exist yet
  3. Must bypass RLS policies
typescript
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:

typescript
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

CategoryTests
Complete FlowStarter/Professional checkout → webhook → profile update
Email FallbackPayment Links without user_id, email normalization
Tier DetectionMetadata priority, subscription lookup, pattern matching
Lifecycle EventsUpgrade, cancellation, payment failure
SecuritySignature verification, missing headers
Edge CasesNo subscription, missing metadata, database failures

Running Integration Tests

bash
# Run subscription flow tests
pnpm test src/api/routes/__tests__/subscription-flow.integration.test.ts

# Run with coverage
pnpm test -- --coverage

Behavior Assertions

Tests use behavior assertions that verify actual data written to the database:

typescript
// 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

VariablePurpose
STRIPE_SECRET_KEYStripe API authentication
STRIPE_STARTER_PRICE_IDPrice ID for starter tier
STRIPE_PROFESSIONAL_PRICE_IDPrice ID for professional tier
PUBLIC_APP_URLBase URL for success/cancel redirects

Required for Webhooks

VariablePurpose
STRIPE_SECRET_KEYStripe API authentication
STRIPE_WEBHOOK_SECRETWebhook signature verification
PUBLIC_SUPABASE_URLSupabase project URL
SUPABASE_SECRET_KEYAdmin access (or legacy SUPABASE_SERVICE_ROLE_KEY)

Setting Secrets

bash
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

Internal documentation - Not for public distribution