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

VariablePurpose
NEXT_PUBLIC_PAYMENT_PROVIDERSet to stripe to select this provider.
STRIPE_API_KEYSecret/restricted key (sk_... or rk_...) for the Stripe SDK.
STRIPE_WEBHOOK_SECRETSigning secret (whsec_...) used to verify webhook signatures.
NEXT_PUBLIC_STRIPE_PRICE_PRO_MONTHLY / _PRO_YEARLY / _LIFETIMEPublic 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.

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:

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 public NEXT_PUBLIC_* 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 NEXT_PUBLIC_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