Stripe dashboard setup
Everything the app needs from the Stripe side, in order. Stripe has two parallel environments: Test mode and Live mode (toggle, top right of the dashboard). Every step below exists separately in each: test keys/prices/webhooks for development and staging, live ones for production. Do the full list twice.
1. Product + price
Product catalog → Add product. One product ("Pro"), one recurring
monthly price. Copy the price id (price_…, on the price row, not the
product id prod_…) into STRIPE_PRO_PRICE_ID.
The amount you set here is what customers pay. The number on the
template's pricing page is display copy (app/pricing/PricingCards.tsx
and the landing-page tiers in app/page.tsx). Keep them in sync when
you change the price.
To change what Pro costs, create a new price on the same product and point
STRIPE_PRO_PRICE_ID at it (Stripe prices are immutable). Existing
subscribers keep renewing on the old price and keep Pro: access follows
the product behind the configured price, not the exact price id. The
product is looked up from Stripe once per process, on the first
subscription event.
2. API key
Developers → API keys. Copy the secret key (sk_test_… /
sk_live_…) into STRIPE_SECRET_KEY. The publishable key (pk_…) is
not used: the template never touches Stripe from the browser; both
Checkout and the Portal are server-minted redirects.
For a tighter blast radius in production, create a restricted key
(rk_…, same page) instead, with write access to Checkout Sessions, Customers,
Subscriptions, and Billing Portal, plus read access to Charges, Prices
(the webhook reads the Pro price once to learn its product) and Invoices
(a refund reads the invoice payment behind the charge): that's the
template's whole API surface. Customers write is what lets checkout
create each user's Stripe customer and account deletion delete it.
3. Webhook endpoint
Developers → Webhooks → Add endpoint.
Endpoint URL:
https://<your-backend-host>/webhooks/stripe(the backend origin, not the frontend origin).Subscribe to exactly these eight events:
checkout.session.completed checkout.session.async_payment_succeeded checkout.session.async_payment_failed customer.subscription.created customer.subscription.updated customer.subscription.deleted charge.refunded charge.dispute.funds_withdrawnAPI version:
2026-09-30.endive, the value ofSTRIPE_API_VERSIONinbilling.config.ts. Payloads arrive in the endpoint's version, not the backend's (see Pinned API version).After creating it, reveal the signing secret (
whsec_…) on the endpoint's page →STRIPE_WEBHOOK_SECRET.
A missing event fails silently: the endpoint 200s everything it does
receive, it just never hears about the rest. In particular, an endpoint
without charge.dispute.funds_withdrawn never sets the dispute lock,
one without customer.subscription.deleted never downgrades churned
customers, and one without
checkout.session.async_payment_succeeded never fulfils a one-time
payment made with an asynchronous method (see the payment_status note in
the table below). Don't subscribe to "all events" either: the handler
skips unknown types harmlessly, but your audit log fills with
skipped_unhandled noise.
The Webhooks page shows delivery attempts and responses: it's the
first place to look when subscription state seems stale. Cross-check
against the billing_webhook_events table, which records what the
backend did with each delivery.
4. Customer Portal
Settings → Billing → Customer portal → Activate. Downgrades, cancellations, payment-method updates, and invoice history all happen here: the app deliberately reimplements none of it. Recommended configuration: allow customers to cancel subscriptions (immediately or at period end, your call), allow updating payment methods, show invoice history. With a single paid plan there's nothing to configure under plan switching.
The portal is per-mode too: activating it in test mode does not
activate it in live mode. An inactive portal makes POST /billing/portal
fail at request time.
5. Failed payments: cancel the subscription
Billing → Revenue recovery → Retries, the setting for what happens to a subscription when all retries for a payment fail. Choose cancel the subscription. This one is required, and it is the only step here with no error to tell you it is missing.
The app keeps paid access on while a subscription is past_due, because
Stripe is retrying the card and most of those recover. It counts on
Stripe ending the subscription when the retries run out: "cancel the
subscription" fires customer.subscription.deleted and the row drops to
canceled/free. "Mark the subscription as unpaid" also ends access, since
unpaid maps to canceled. "Leave the subscription past-due" means
past_due grants paid access forever: no event ever reports that the
retries are over, so a customer whose card stopped working keeps Pro.
6. Stripe Tax (optional)
Only if you set STRIPE_AUTOMATIC_TAX=true: complete Settings → Tax
first (origin address, registrations). With the flag on and Tax
unconfigured, every checkout fails at session creation. Each session
then also sets customer_update: { address: 'auto' }, so the address the
buyer enters is saved to their Stripe customer, which Stripe Tax requires
for an existing customer. Remember tax registration and filing stay
with you: Stripe Tax calculates and collects, it doesn't file.
7. backend/.env
STRIPE_SECRET_KEY=sk_test_… # step 2
STRIPE_WEBHOOK_SECRET=whsec_… # step 3 (or npm run dev:stripe locally)
STRIPE_PRO_PRICE_ID=price_… # step 1 (subscriptions only)
FRONTEND_URL=http://localhost:3000 # checkout/portal return origin
# STRIPE_AUTOMATIC_TAX=true # step 6, optional
Dev loop (no dashboard webhook needed)
Locally, the Stripe CLI replaces step 3:
cd backend && npm run dev:stripe # wraps: stripe listen --forward-to localhost:4000/webhooks/stripe
stripe listen prints a whsec_… signing secret on startup: that's
your dev STRIPE_WEBHOOK_SECRET (it differs from any dashboard
endpoint's secret). It forwards all events, so no event selection
applies in dev. It renders them at your account's default API version
(shown in Workbench), so the dev loop exercises the pinned shape
only when that default matches STRIPE_API_VERSION. Test cards:
4242 4242 4242 4242, any future expiry/CVC. Trigger individual events with
stripe trigger checkout.session.completed.
Smoke test the full loop
With test keys configured and npm run dev:stripe running: sign in →
/pricing → Upgrade to Pro → pay with 4242 4242 4242 4242 → land on
/billing/success (it may show "Finishing up…" for a second while the
webhook races the redirect) → Settings shows Pro with a renewal date →
"Manage billing" opens the Portal → cancel there → Settings drops to
Free. If any step stalls, read the stripe listen output and the
billing_webhook_events table in that order.