Checkout
The pricing page posts a plain form — no JavaScript required:
<form method="POST" action="/api/stripe/checkout">
<input type="hidden" name="plan" value="pro" />
<button type="submit">Subscribe to Pro</button>
</form> The endpoint (src/routes/api/stripe/checkout/+server.ts) then:
- Validates the plan against
plans.ts(free plans never reach Stripe) - Requires a session — anonymous users redirect to
/auth/register?next=/pricing - Resolves the Stripe customer: reuses the stored
stripe_customer_id, or creates one with the user’s email +user_idmetadata - Creates a subscription-mode Checkout Session with
client_reference_id = user.idand plan metadata on both session and subscription, so the webhook can attribute it later - Redirects (303) to
session.url— Stripe’s hosted payment page
Success returns to /app/dashboard/billing?checkout=success; cancel returns
to /pricing?checkout=cancelled.
Price resolution
For each plan the endpoint checks getPriceIdForPlan(plan.id):
- Set (
STRIPE_PRICE_PRO) → uses that existing Stripe Price - Unset → builds inline
price_datafromplans.tsamounts, creating the price on the fly. This is why the template works before you create anything in the Stripe dashboard.
Customer portal
The billing page’s Manage Billing in Stripe button POSTs to /api/stripe/portal, which:
- Requires a session (redirects to login with
nextotherwise) - Resolves or lazily creates the customer (same lookup as checkout)
- Creates a Customer Portal Session with
return_url = {origin}/app/dashboard/billing
Users can update cards, download invoices, switch plans, and cancel there. One-time setup: activate the portal in Stripe → Settings → Billing → Customer portal.
After checkout
On return, the billing page reads its subscription row and shows a success
banner with a refresh action. The authoritative state arrives asynchronously
when Stripe delivers checkout.session.completed — see Webhooks.