Stripe API
Handles Stripe Checkout, in-app subscription management (upgrade, downgrade, cancel, reactivate), and Customer Portal access.
Base Path: /api/stripe
Source Files:
src/api/routes/stripe/checkout.tssrc/api/routes/stripe/portal.tssrc/api/routes/stripe/change-plan.tssrc/api/routes/stripe/preview-change.ts
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /checkout | No | Create checkout session |
POST | /portal | No | Get customer portal URL |
POST | /change-plan | Yes | Upgrade, downgrade, or cancel subscription |
POST | /change-plan/reactivate | Yes | Reverse a pending cancellation |
POST | /preview-change | Yes | Preview proration for a plan change |
Create Checkout Session
Creates a Stripe Checkout session for subscription signup. The session includes metadata to link the subscription to the user.
POST /api/stripe/checkoutRequest Body:
{
"tier": "starter",
"userId": "uuid-of-user",
"email": "[email protected]",
"customerId": "cus_xxx"
}Fields:
| Field | Type | Required | Description |
|---|---|---|---|
tier | 'starter' | 'professional' | Yes | Subscription tier to purchase |
userId | string | Yes | Supabase user ID (stored in metadata) |
email | string | Yes | User email for receipt (ignored if customerId provided) |
customerId | string | No | Existing Stripe customer ID |
Response:
{
"url": "https://checkout.stripe.com/pay/cs_xxx",
"sessionId": "cs_xxx"
}Error Responses:
| Code | Error | Cause |
|---|---|---|
400 | Missing required fields | tier, userId, or email not provided |
500 | Server configuration error | Missing STRIPE_SECRET_KEY or PUBLIC_APP_URL |
500 | Price not configured for tier | Missing STRIPE_STARTER_PRICE_ID or STRIPE_PROFESSIONAL_PRICE_ID |
500 | Failed to create checkout session | Stripe API failure |
Session Configuration
The checkout session is created with the following settings:
{
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${PUBLIC_APP_URL}/pricing?canceled=true`,
customer_email: customerId ? undefined : email,
customer: customerId || undefined,
metadata: {
user_id: userId,
tier: tier
},
subscription_data: {
metadata: {
user_id: userId,
tier: tier
}
}
}Checkout Flow
Subscription Tiers
| Tier | Environment Variable | Features |
|---|---|---|
| Starter | STRIPE_STARTER_PRICE_ID | 1 podcast, basic features |
| Professional | STRIPE_PROFESSIONAL_PRICE_ID | 3 podcasts, automation |
Customer Portal
Creates a Stripe Customer Portal session for subscription management.
POST /api/stripe/portalRequest Body:
{
"customerId": "cus_xxx",
"returnUrl": "https://app.example.com/settings"
}Fields:
| Field | Type | Required | Description |
|---|---|---|---|
customerId | string | Yes | Stripe customer ID |
returnUrl | string | No | URL to return after portal (default: /settings) |
Response:
{
"url": "https://billing.stripe.com/session/xxx"
}Error Responses:
| Code | Error | Cause |
|---|---|---|
400 | Customer ID is required | Missing customerId in request |
500 | Server configuration error | Missing STRIPE_SECRET_KEY |
500 | Failed to create portal session | Stripe API failure |
Portal Capabilities
The Customer Portal allows users to:
- View subscription details
- Update payment method
- Cancel subscription
- View invoice history
- Download invoices
Change Subscription Plan
Changes the user's subscription tier. Handles three scenarios: upgrade (immediate proration charge), downgrade (credit on next invoice), and cancel to free (cancel at period end).
POST /api/stripe/change-planAuth: Required (Authorization: Bearer <token>)
Rate Limit: 5 requests/minute per user
Request Body:
{
"targetTier": "professional",
"forceDeletePending": false
}Fields:
| Field | Type | Required | Description |
|---|---|---|---|
targetTier | 'starter' | 'professional' | 'free' | Yes | Tier to switch to |
forceDeletePending | boolean | No | Force-delete pending_deletion podcasts to free slots (default: false) |
Success Response (200):
{
"success": true,
"data": {
"action": "upgrade",
"newTier": "professional",
"message": "Upgraded to professional. Your card has been charged for the upgrade."
}
}Action values: upgrade, downgrade, cancel_at_period_end
Error Responses:
| Code | Error | Cause |
|---|---|---|
400 | Plan changes not available | User has billing_model of ltd or enterprise_manual |
400 | Already on this plan | targetTier matches current tier |
400 | No active subscription | No subscription found and target is not free |
402 | Card declined | Upgrade proration invoice payment failed |
409 | Too many podcasts | Podcast count exceeds target tier's limit |
500 | Server configuration error | Missing STRIPE_SECRET_KEY |
Podcast Limit Enforcement (409 Response)
When downgrading and the user has too many podcasts:
{
"error": "You have 4 active podcast(s). Starter plan allows 3. Please delete podcasts before downgrading.",
"data": {
"occupiedSlots": 4,
"targetLimit": 3,
"excessPodcasts": [
{ "id": "uuid", "title": "My Podcast", "slug": "my-podcast", "status": "active" }
],
"canForceDelete": true
}
}If canForceDelete is true, the user can retry with forceDeletePending: true to immediately remove pending_deletion podcasts and proceed.
Upgrade vs Downgrade Behavior
| Direction | Stripe Behavior | DB Write |
|---|---|---|
| Upgrade | always_invoice + error_if_incomplete — charges proration immediately, fails on card decline | Writes subscription_tier immediately |
| Downgrade | create_prorations — credit applied to next invoice | Writes subscription_tier immediately |
| Cancel to free | cancel_at_period_end: true — access continues until period end | Writes subscription_cancel_at_period_end: true |
Reactivate Subscription
Reverses a pending cancellation by setting cancel_at_period_end back to false.
POST /api/stripe/change-plan/reactivateAuth: Required (Authorization: Bearer <token>)
Rate Limit: 5 requests/minute per user
Request Body: None (empty JSON {})
Success Response (200):
{
"success": true,
"data": {
"action": "reactivated",
"message": "Your subscription has been reactivated and will continue as normal."
}
}Error Responses:
| Code | Error | Cause |
|---|---|---|
400 | No active subscription | No subscription ID found |
400 | Not pending cancellation | subscription_cancel_at_period_end is already false |
500 | Server configuration error | Missing STRIPE_SECRET_KEY |
Preview Plan Change
Returns proration details for a proposed plan change. Read-only — does not mutate Stripe or the database.
POST /api/stripe/preview-changeAuth: Required (Authorization: Bearer <token>)
Rate Limit: 20 requests/minute per user
Request Body:
{
"targetTier": "professional"
}Fields:
| Field | Type | Required | Description |
|---|---|---|---|
targetTier | 'starter' | 'professional' | 'free' | Yes | Tier to preview |
Success Response (200):
{
"success": true,
"data": {
"currentTier": "starter",
"targetTier": "professional",
"action": "upgrade",
"currentPeriodEnd": "2026-05-15T00:00:00.000Z",
"prorationAmount": 500,
"immediateCharge": 500,
"newMonthlyRate": 2900
}
}Data fields:
| Field | Type | Description |
|---|---|---|
currentTier | string | User's current tier |
targetTier | string | Requested tier |
action | string | upgrade, downgrade, or cancel_at_period_end |
currentPeriodEnd | string | null | ISO date of billing period end |
prorationAmount | number | Proration in cents (positive = charge, negative = credit) |
immediateCharge | number | Amount charged now (0 for downgrades) |
newMonthlyRate | number | New monthly rate in cents |
Error Responses:
| Code | Error | Cause |
|---|---|---|
400 | Not available for billing type | Non-subscription billing model |
400 | Already on this plan | Same tier as current |
400 | No active subscription | No Stripe subscription found |
500 | Price not configured | Missing env var for target tier |
TypeScript Client Usage
import { createApiClient } from '$api/client';
const client = createApiClient(fetch);
// Create checkout session (note: tier-based, not price_id)
const checkoutRes = await client.api.stripe.checkout.$post({
json: {
tier: 'starter',
userId: user.id,
email: user.email,
customerId: profile?.stripe_customer_id
}
});
if (checkoutRes.ok) {
const { url } = await checkoutRes.json();
window.location.href = url;
}
// Get portal URL
const portalRes = await client.api.stripe.portal.$post({
json: {
customerId: profile.stripe_customer_id,
returnUrl: '/dashboard'
}
});
if (portalRes.ok) {
const { url } = await portalRes.json();
window.location.href = url;
}
// Preview a plan change (read-only)
const previewRes = await fetch('/api/stripe/preview-change', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ targetTier: 'professional' })
});
// Change plan (upgrade/downgrade/cancel)
const changeRes = await fetch('/api/stripe/change-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ targetTier: 'professional', forceDeletePending: false })
});
// Reactivate a pending cancellation
const reactivateRes = await fetch('/api/stripe/change-plan/reactivate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({})
});Test Coverage
All endpoints have comprehensive test coverage:
src/api/routes/stripe/__tests__/checkout.test.ts:
- Configuration validation (missing secrets)
- Request validation (missing required fields)
- Tier-based price ID resolution
- Existing customer handling
- Stripe API error handling
- Metadata inclusion for webhook reconciliation
src/api/routes/stripe/__tests__/portal.test.ts:
- Configuration validation
- Customer ID requirement
- Custom return URL handling
- Default return URL fallback
- Stripe API error handling
- Security (POST-only, no internal error exposure)
src/api/routes/stripe/__tests__/change-plan.test.ts:
- Billing model guard (
ltdandenterprise_manualrejected) - Same-tier rejection
- Upgrade with proration (
always_invoice+error_if_incomplete) - Downgrade with credit (
create_prorations) - Cancel to free (
cancel_at_period_end) - Podcast limit enforcement (409 response)
- Force-delete pending podcasts for downgrade
- Card decline handling (402 response)
- Reactivation of pending cancellation
- Rate limiting
src/api/utils/__tests__/stripe-sync.test.ts:
resolveStripeSubscription()— DB cache hit, Stripe API fallback, cachingshouldSyncToStripe()— skip for enterprise, LTD, missing subscriptioncancelSubscriptionAtPeriodEnd()/uncancelSubscription()— side-effect safetypauseSubscriptionBilling()/resumeSubscriptionBilling()
Related
- Subscription Flow Guide - End-to-end flow documentation
- Stripe Integration - Full Stripe documentation
- Webhooks API - Webhook handling