SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Getting Started
Installation
Configuration
Deployment

Components

  • Hero
  • Pricing
  • Features

Features

  • Authentication
  • Payments
  • Emails
  • Database
  • Blog
  • Security Headers
  • Claude Code Skills

Recipes

  • Add a Server Action
  • Add a Database Table
  • Add an OAuth Provider
  • Add an Email Template
  • Customize the Auth Flow
  • Add an Admin Metric
  • Enable Bot Protection

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

  1. Create your products and prices in the Stripe Dashboard. For one-time pricing, set the price type to "One-time."
  2. Copy the price IDs to config.ts under billing.plans[].priceId. The template uses the same ID for both monthly and yearly because one-time prices do not vary by interval.
  3. Set the Stripe keys in .env.local: STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and (optionally) NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY.
  4. 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:

  1. User clicks a plan on the pricing page.
  2. The createCheckoutSession Server 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 in mode: 'payment'.
  3. Stripe redirects the user to its hosted Checkout UI.
  4. On success, Stripe fires checkout.session.completed to the webhook endpoint.
  5. The webhook handler inserts a row into the purchases table 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 the customer.subscription.created/updated cases.
  • Invoice subscription ID is at invoice.parent.subscription_details.subscription, not the older invoice.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:

  1. In actions/billing.ts, change mode: 'payment' to mode: 'subscription' in the createCheckoutSession call.
  2. In the Stripe Dashboard, create your products with recurring price types instead of one-time. Copy the new price IDs to config.ts.
  3. The webhook handler will route customer.subscription.* events to the subscriptions table automatically.
  4. Add subscription-status checks wherever your app gates access (e.g., a Server Action that reads from subscriptions and confirms status = 'active' or status = '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.