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
| Variable | Where | Purpose |
|---|---|---|
VITE_PAYMENT_PROVIDER | Build-time | Set to stripe to select this provider. |
STRIPE_API_KEY | Worker secret | Secret/restricted key (sk_... or rk_...) for the Stripe SDK. |
STRIPE_WEBHOOK_SECRET | Worker secret | Signing secret (whsec_...) used to verify webhook signatures. |
VITE_STRIPE_PRICE_PRO_MONTHLY / _PRO_YEARLY / _LIFETIME | Build-time | Public Price IDs (price_...) for the three plans. |
wrangler secret put STRIPE_API_KEY
wrangler secret put STRIPE_WEBHOOK_SECRETStripe values are Price IDs (price_...), not Product IDs. The client is
pinned to apiVersion: "2026-05-27.dahlia" in providers/stripe.ts.
Checkout
createCheckout maps the plan's productType to a Checkout mode:
productType | Mode | Result |
|---|---|---|
month / year | subscription | Recurring subscription |
one_time | payment | One-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/$providerroute). - Verification:
stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET)against thestripe-signatureheader. A missing/invalid signature answers400. - Handled events:
| Event | Effect |
|---|---|
checkout.session.completed / checkout.session.async_payment_succeeded | If paid, mark the payment row success; for payment mode, grant lifetime access. |
checkout.session.async_payment_failed | Mark the payment row failed. |
checkout.session.expired | Mark the payment row closed. |
customer.subscription.created / updated / deleted | Upsert the user's subscription row (status, period, trial, cancel timestamps). |
invoice.paid | Stamp lastPaidAt on the matching subscription. |
charge.refunded / refund.created | Mark 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/stripeThe 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/stripeand subscribe to the events listed above; copy its signing secret intoSTRIPE_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
activesubscriptionrow with the correct period. - A lifetime (one-time) purchase creates a
one_timerow with a far-futureperiodEnd. -
Manage billingopens the Stripe Billing Portal. - An invalid webhook signature returns
400.
