Skip to content

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.ts
  • src/api/routes/stripe/portal.ts
  • src/api/routes/stripe/change-plan.ts
  • src/api/routes/stripe/preview-change.ts

Endpoints

MethodPathAuthDescription
POST/checkoutNoCreate checkout session
POST/portalNoGet customer portal URL
POST/change-planYesUpgrade, downgrade, or cancel subscription
POST/change-plan/reactivateYesReverse a pending cancellation
POST/preview-changeYesPreview 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/checkout

Request Body:

json
{
	"tier": "starter",
	"userId": "uuid-of-user",
	"email": "[email protected]",
	"customerId": "cus_xxx"
}

Fields:

FieldTypeRequiredDescription
tier'starter' | 'professional'YesSubscription tier to purchase
userIdstringYesSupabase user ID (stored in metadata)
emailstringYesUser email for receipt (ignored if customerId provided)
customerIdstringNoExisting Stripe customer ID

Response:

json
{
	"url": "https://checkout.stripe.com/pay/cs_xxx",
	"sessionId": "cs_xxx"
}

Error Responses:

CodeErrorCause
400Missing required fieldstier, userId, or email not provided
500Server configuration errorMissing STRIPE_SECRET_KEY or PUBLIC_APP_URL
500Price not configured for tierMissing STRIPE_STARTER_PRICE_ID or STRIPE_PROFESSIONAL_PRICE_ID
500Failed to create checkout sessionStripe API failure

Session Configuration

The checkout session is created with the following settings:

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

TierEnvironment VariableFeatures
StarterSTRIPE_STARTER_PRICE_ID1 podcast, basic features
ProfessionalSTRIPE_PROFESSIONAL_PRICE_ID3 podcasts, automation

Customer Portal

Creates a Stripe Customer Portal session for subscription management.

POST /api/stripe/portal

Request Body:

json
{
	"customerId": "cus_xxx",
	"returnUrl": "https://app.example.com/settings"
}

Fields:

FieldTypeRequiredDescription
customerIdstringYesStripe customer ID
returnUrlstringNoURL to return after portal (default: /settings)

Response:

json
{
	"url": "https://billing.stripe.com/session/xxx"
}

Error Responses:

CodeErrorCause
400Customer ID is requiredMissing customerId in request
500Server configuration errorMissing STRIPE_SECRET_KEY
500Failed to create portal sessionStripe 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-plan

Auth: Required (Authorization: Bearer <token>)

Rate Limit: 5 requests/minute per user

Request Body:

json
{
	"targetTier": "professional",
	"forceDeletePending": false
}

Fields:

FieldTypeRequiredDescription
targetTier'starter' | 'professional' | 'free'YesTier to switch to
forceDeletePendingbooleanNoForce-delete pending_deletion podcasts to free slots (default: false)

Success Response (200):

json
{
	"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:

CodeErrorCause
400Plan changes not availableUser has billing_model of ltd or enterprise_manual
400Already on this plantargetTier matches current tier
400No active subscriptionNo subscription found and target is not free
402Card declinedUpgrade proration invoice payment failed
409Too many podcastsPodcast count exceeds target tier's limit
500Server configuration errorMissing STRIPE_SECRET_KEY

Podcast Limit Enforcement (409 Response)

When downgrading and the user has too many podcasts:

json
{
	"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

DirectionStripe BehaviorDB Write
Upgradealways_invoice + error_if_incomplete — charges proration immediately, fails on card declineWrites subscription_tier immediately
Downgradecreate_prorations — credit applied to next invoiceWrites subscription_tier immediately
Cancel to freecancel_at_period_end: true — access continues until period endWrites 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/reactivate

Auth: Required (Authorization: Bearer <token>)

Rate Limit: 5 requests/minute per user

Request Body: None (empty JSON {})

Success Response (200):

json
{
	"success": true,
	"data": {
		"action": "reactivated",
		"message": "Your subscription has been reactivated and will continue as normal."
	}
}

Error Responses:

CodeErrorCause
400No active subscriptionNo subscription ID found
400Not pending cancellationsubscription_cancel_at_period_end is already false
500Server configuration errorMissing 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-change

Auth: Required (Authorization: Bearer <token>)

Rate Limit: 20 requests/minute per user

Request Body:

json
{
	"targetTier": "professional"
}

Fields:

FieldTypeRequiredDescription
targetTier'starter' | 'professional' | 'free'YesTier to preview

Success Response (200):

json
{
	"success": true,
	"data": {
		"currentTier": "starter",
		"targetTier": "professional",
		"action": "upgrade",
		"currentPeriodEnd": "2026-05-15T00:00:00.000Z",
		"prorationAmount": 500,
		"immediateCharge": 500,
		"newMonthlyRate": 2900
	}
}

Data fields:

FieldTypeDescription
currentTierstringUser's current tier
targetTierstringRequested tier
actionstringupgrade, downgrade, or cancel_at_period_end
currentPeriodEndstring | nullISO date of billing period end
prorationAmountnumberProration in cents (positive = charge, negative = credit)
immediateChargenumberAmount charged now (0 for downgrades)
newMonthlyRatenumberNew monthly rate in cents

Error Responses:

CodeErrorCause
400Not available for billing typeNon-subscription billing model
400Already on this planSame tier as current
400No active subscriptionNo Stripe subscription found
500Price not configuredMissing env var for target tier

TypeScript Client Usage

typescript
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 (ltd and enterprise_manual rejected)
  • 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, caching
  • shouldSyncToStripe() — skip for enterprise, LTD, missing subscription
  • cancelSubscriptionAtPeriodEnd() / uncancelSubscription() — side-effect safety
  • pauseSubscriptionBilling() / resumeSubscriptionBilling()

Internal documentation - Not for public distribution