nextjsboilerplate Docs
Architecture

Billing — Stripe webhook flow, planned Paddle / Polar

How payments, subscriptions, and credits work today, and how to swap providers without rewriting the dashboard.

What's shipped: Stripe

Three independent surfaces, all backed by stripe@19:

  • One-shot credit packssrc/config/credits.ts lists pack SKUs, the Checkout session is created server-side from src/actions/checkout.ts, the webhook upserts a credits_ledger row.
  • Subscription tiersfree / pro / scale in src/config/billing.ts, Stripe Price IDs wired via STRIPE_PRICE_<TIER>_<PERIOD> env vars, subscriptions table in the DB.
  • Invoices — emitted by Stripe; the webhook stores the hosted invoice URL for the dashboard's billing page.

The webhook flow

Stripe

  │ POST /api/stripe/webhook
  │ (signed with STRIPE_WEBHOOK_SECRET)

src/app/api/stripe/webhook/route.ts

  │ 1. Idempotency.handle()  ← keyed by Stripe-Event-Id
  │ 2. stripe.webhooks.constructEvent()  ← verify signature
  │ 3. dispatch by event.type:
  │     - customer.subscription.created/updated/deleted
  │     - invoice.payment_succeeded
  │     - checkout.session.completed
  │ 4. update DB (subscriptions / credits_ledger)
  │ 5. AuditChain.append({ kind: 'subscription.upgraded', ... })
  │ 6. return 200 fast (Stripe expects <10s)

DB + audit_chain row

Two non-obvious choices:

  1. The webhook is idempotent at the framework layer, not in business logic. Idempotency.handle() keyed on Stripe-Event-Id means a retried delivery replays the cached response and never re-fires audit events.
  2. The webhook gracefully 200s when STRIPE_WEBHOOK_SECRET is unset — a dev-safety fallback so local dev without Stripe stays unblocked. The contract is documented in CLAUDE.md.

Required env vars

STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_...
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_YEARLY=price_...
STRIPE_PRICE_SCALE_MONTHLY=price_...
STRIPE_PRICE_SCALE_YEARLY=price_...

Verify locally with the Stripe CLI:

stripe listen --forward-to localhost:3000/api/stripe/webhook
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded

Planned: Paddle / Polar

Stripe is the default because it's the most asked-for. But it's not the right fit for every audience:

  • Paddle — Merchant-of-Record. They handle VAT/sales tax in the EU and US. This matters for solo founders who don't want to register a VAT entity in every country.
  • Polar — open-source-first. The pricing model and API are simpler, and they explicitly market themselves as a Stripe alternative for indie SaaS.

Migration shape

The plan is to extract a BillingProvider interface in src/libs/Billing/ matching the four operations the app actually does:

type BillingProvider = {
  createCheckoutSession(input: CheckoutInput): Promise<{ url: string }>;
  createPortalSession(customerId: string): Promise<{ url: string }>;
  cancelSubscription(subscriptionId: string): Promise<void>;
  verifyWebhook(rawBody: string, signature: string): Promise<WebhookEvent>;
};

The Stripe implementation moves to src/libs/Billing/stripe.ts. Paddle and Polar adapters become drop-in replacements. The dashboard's billing page reads from subscriptions (DB) — it has no idea which provider populated the row.

Why not Lemon Squeezy

Lemon Squeezy was acquired by Stripe in 2024. Stripe Tax + Stripe directly covers their use case, so it's not on the roadmap.

Money discipline

Every monetary value in the codebase is integer cents, never a float. Migration 0008-subscriptions and 0017-llm enforce this at the column type level (integer NOT NULL with _usd_cents suffix). The repo rules ban floats for money — see rules/core.md §6.

This avoids the 0.1 + 0.2 !== 0.3 class of bugs that bites every billing system that uses floats for prices.

On this page