Stripe

Stripe Checkout, subscriptions, lifetime, and the billing portal for the TanStack Start template.

Stripe implements the shared PaymentProvider interface in src/lib/payment/providers/stripe.ts. It uses Stripe Checkout for both recurring plans and one-time (lifetime) purchases, syncs subscriptions from webhooks, and exposes the Billing Portal for self-service management.

Configuration

VariableWherePurpose
VITE_PAYMENT_PROVIDERBuild-timeSet to stripe to select this provider.
STRIPE_API_KEYWorker secretSecret/restricted key (sk_... or rk_...) for the Stripe SDK.
STRIPE_WEBHOOK_SECRETWorker secretSigning secret (whsec_...) used to verify webhook signatures.
VITE_STRIPE_PRICE_PRO_MONTHLY / _PRO_YEARLY / _LIFETIMEBuild-timePublic Price IDs (price_...) for the three plans.
wrangler secret put STRIPE_API_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET

Stripe values are Price IDs (price_...), not Product IDs. The client is pinned to apiVersion: "2026-05-27.dahlia" in providers/stripe.ts.

See Environment Variables.

Checkout

createCheckout maps the plan's productType to a Checkout mode:

productTypeModeResult
month / yearsubscriptionRecurring subscription
one_timepaymentOne-off lifetime purchase

It attaches metadata (orderNo, productId, productType, userId) to the session — and, for subscriptions, to subscription_data so later subscription events can be traced back to the user. If the user already has a Stripe customerId, the session reuses it; otherwise Stripe creates a customer from customer_email.

Webhook

  • Path: POST /api/payment/notify/stripe (served by the shared /api/payment/notify/$provider route).
  • Verification: stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET) against the stripe-signature header. A missing/invalid signature answers 400.
  • Handled events:
EventEffect
checkout.session.completed / checkout.session.async_payment_succeededIf paid, mark the payment row success; for payment mode, grant lifetime access.
checkout.session.async_payment_failedMark the payment row failed.
checkout.session.expiredMark the payment row closed.
customer.subscription.created / updated / deletedUpsert the user's subscription row (status, period, trial, cancel timestamps).
invoice.paidStamp lastPaidAt on the matching subscription.
charge.refunded / refund.createdMark the matching payment row refunded.

Subscription sync

syncStripeSubscription maps the Stripe status to the local enum (active / trialing / canceled / expired / unpaid), derives month vs year from the price interval, and writes the period window.

Each user has a single entitlement row. A guard prevents a late event from an old recurring subscription from overwriting a one_time (lifetime) purchase. Keep this guard if you customize the sync.

Lifetime purchases

For payment-mode checkouts, syncLifetimeAccess writes a one_time subscription row with status: active and periodEnd far in the future, so the user has permanent access without a Stripe subscription object.

Billing portal

createPortal opens a Stripe Billing Portal session. POST /api/payment/portal reads the user's subscription.customerId and returns the portal URL; the ManageBillingButton component (src/components/payment/ManageBillingButton.tsx) links to it. The portal is only available for Stripe customers (Creem omits createPortal).

Local testing

Use the Stripe CLI to forward events to your dev server (vite dev runs on port 3000):

stripe listen --forward-to localhost:3000/api/payment/notify/stripe

The CLI prints a whsec_... secret — set it as STRIPE_WEBHOOK_SECRET. Then complete a checkout with a test card (e.g. 4242 4242 4242 4242) and confirm the payment row flips to success and a subscription row appears.

Production notes

  • Use live keys and live Price IDs (rebuild for the VITE_* IDs).
  • Register a webhook endpoint at /api/payment/notify/stripe and subscribe to the events listed above; copy its signing secret into STRIPE_WEBHOOK_SECRET.
  • Add idempotency — Stripe retries deliveries; handlers must be safe to run twice.
  • Prefer a restricted key (rk_...) scoped to Checkout, Customers, Subscriptions, and Billing Portal.

Verify

  • With VITE_PAYMENT_PROVIDER=stripe, the pricing CTA opens Stripe Checkout.
  • A subscription purchase creates an active subscription row with the correct period.
  • A lifetime (one-time) purchase creates a one_time row with a far-future periodEnd.
  • Manage billing opens the Stripe Billing Portal.
  • An invalid webhook signature returns 400.

On this page