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 packs —
src/config/credits.tslists pack SKUs, the Checkout session is created server-side fromsrc/actions/checkout.ts, the webhook upserts acredits_ledgerrow. - Subscription tiers —
free / pro / scaleinsrc/config/billing.ts, Stripe Price IDs wired viaSTRIPE_PRICE_<TIER>_<PERIOD>env vars,subscriptionstable 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 rowTwo non-obvious choices:
- The webhook is idempotent at the framework layer, not in business
logic.
Idempotency.handle()keyed onStripe-Event-Idmeans a retried delivery replays the cached response and never re-fires audit events. - The webhook gracefully 200s when
STRIPE_WEBHOOK_SECRETis unset — a dev-safety fallback so local dev without Stripe stays unblocked. The contract is documented inCLAUDE.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_succeededPlanned: 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.
Authentication — NextAuth → Better Auth migration plan
Why we ship with NextAuth today, why we're moving to Better Auth, and the migration path that won't lock you in.
Database — Drizzle schema and migration discipline
How the schema is organized, why migrations are append-only, and what to do when you need to change a column.