Stripe
Stripe Checkout, subscriptions, lifetime, and the billing portal for the Next.js 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 | Purpose |
|---|---|
NEXT_PUBLIC_PAYMENT_PROVIDER | Set to stripe to select this provider. |
STRIPE_API_KEY | Secret/restricted key (sk_... or rk_...) for the Stripe SDK. |
STRIPE_WEBHOOK_SECRET | Signing secret (whsec_...) used to verify webhook signatures. |
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY / _PRO_YEARLY / _LIFETIME | Public Price IDs (price_...) for the three plans. |
Stripe 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/[provider]route). - 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:
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 public
NEXT_PUBLIC_*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
NEXT_PUBLIC_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.
