Payments
Stripe one-time billing by default, with optional subscription scaffolding. Server-only key access, signature-verified webhooks, idempotent event handling.
What's enforced
Payments run through Stripe Checkout. The default is mode: 'payment' (one-time), set in actions/billing.ts at the createCheckoutSession call. The Stripe secret key is read from process.env.STRIPE_SECRET_KEY server-side only and never reaches the browser. Webhook signatures are verified against STRIPE_WEBHOOK_SECRET before any event is processed; a missing or invalid signature returns 400 and the event is dropped.
The webhook handler at app/api/webhooks/stripe/route.ts handles checkout.session.completed for one-time orders and ALSO handles the full subscription lifecycle (customer.subscription.created/updated/deleted, invoice.payment_failed, charge.dispute.created). The schema includes both a purchases table (one-time) and a subscriptions table (subscription), so switching modes later does not require a database migration.
One-time is the default. Here is why.
SecureStartKit ships mode: 'payment' exclusively in the default createCheckoutSession. A template is a one-time architectural decision: you adopt it once, and the value is in the foundation, not in ongoing access. The decision framework, the 6 Stripe-mechanic differences, and the trade-offs are walked through in detail on the /compare/one-time-vs-subscription-saas-billing page.
If your SaaS delivers ongoing value (hosting, monitoring, content, continuous service), subscription is the right call and the section "Switching to subscription mode" below covers what to change.
Setup
- Create your products and prices in the Stripe Dashboard. For one-time pricing, set the price type to "One-time."
- Copy the price IDs to
config.tsunderbilling.plans[].priceId. The template uses the same ID for bothmonthlyandyearlybecause one-time prices do not vary by interval. - Set the Stripe keys in
.env.local:STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET, and (optionally)NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY. - Configure the webhook endpoint per the instructions in the Installation guide.
Checkout flow
The flow is one Server Action, one Stripe Customer record, and one redirect:
- User clicks a plan on the pricing page.
- The
createCheckoutSessionServer Action runs server-side. It validates the input with Zod, gets-or-creates a Stripe Customer for the authenticated user, and creates a Checkout Session inmode: 'payment'. - Stripe redirects the user to its hosted Checkout UI.
- On success, Stripe fires
checkout.session.completedto the webhook endpoint. - The webhook handler inserts a row into the
purchasestable and triggers two emails via Resend: the delivery email (repo access for the buyer) and a notification email to the admin.
The webhook handler uses the Stripe payment intent ID (or session ID fallback) as the purchases.id primary key, which gives idempotency on duplicate webhook deliveries: a re-delivered event tries to insert the same primary key and the database rejects it. No dedupe-table or processed-events tracking is needed for the one-time flow.
Webhook security
The webhook route is the highest-risk surface in the payment flow. Three properties are enforced.
Signature verification before anything else. The handler reads the raw request body, extracts the stripe-signature header, and calls getStripe().webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET). If the signature does not match, the function throws and the handler returns 400. Nothing else runs.
Service-role access only. The handler uses createAdminClient() to write to purchases. The service_role key is never exposed to client code; it lives in SUPABASE_SERVICE_ROLE_KEY on the server. The browser Supabase client cannot insert into purchases because RLS denies all to the anon key.
Event-type allowlist. The handler maintains a relevantEvents set. Events outside the set return received: true without further processing, which prevents Stripe re-delivery on event types the app does not yet support and avoids accidentally handling something the integration was not designed for.
For a deeper walkthrough of the 5 specific webhook-verification failure modes (parsed body bytes, JSON re-stringify, timestamp drift, wrong endpoint secret, v0 fake-test-scheme acceptance), see Stripe webhook signature verification in Next.js.
Customer Portal
createPortalSession in actions/billing.ts creates a Stripe billing-portal session for the authenticated user. The portal lets the user view receipts, update billing info, and (if you ship subscriptions) manage their subscription.
For the default one-time flow, the portal is mainly useful for receipt access and address updates. If a buyer asks for a refund, you process it from your Stripe dashboard, not from the portal. If you ship subscriptions, the portal becomes the customer-self-serve surface for upgrades, downgrades, and cancellations.
Stripe API version
The template targets Stripe SDK v21 with the 2026-03-25.dahlia API version. Two version-specific behaviors to keep in mind if you customize the webhook:
- Subscription period fields are on
subscription.items.data[0], not the top-level subscription object. The handler already accounts for this in thecustomer.subscription.created/updatedcases. - Invoice subscription ID is at
invoice.parent.subscription_details.subscription, not the olderinvoice.subscription.
Confirm your Stripe account's API version under Dashboard > Developers > API version. Older versions cause silent webhook failures because the field paths differ.
Switching to subscription mode
The webhook handler and database schema already support subscriptions. To switch the default checkout flow:
- In
actions/billing.ts, changemode: 'payment'tomode: 'subscription'in thecreateCheckoutSessioncall. - In the Stripe Dashboard, create your products with
recurringprice types instead of one-time. Copy the new price IDs toconfig.ts. - The webhook handler will route
customer.subscription.*events to thesubscriptionstable automatically. - Add subscription-status checks wherever your app gates access (e.g., a Server Action that reads from
subscriptionsand confirmsstatus = 'active'orstatus = 'trialing').
The Customer Portal, the subscriptions table, the lifecycle webhook handlers, and the current_period_start/end fields are all in place. The only additional architectural work is the access-gating layer, which is application-specific.
Read /compare/one-time-vs-subscription-saas-billing before switching. The 6 mechanical differences (extra webhook handlers, customer data retention, recurring tax math, dunning rules, pro-rata refunds, idempotency-at-scale) all become your responsibility once subscriptions are live.
Local testing
Forward webhooks to your dev server with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Copy the signing secret it prints (whsec_...) into STRIPE_WEBHOOK_SECRET in .env.local. You can then trigger test events with stripe trigger checkout.session.completed or run the full checkout flow with the test card 4242 4242 4242 4242.
For a fully free end-to-end test, create a 100%-off coupon in Stripe and apply it at checkout. The full webhook + email flow runs without a real charge.
For verifying signatures and event payloads by hand without running the full app, use the Stripe Webhook Verifier tool.