Billing & subscriptions
Stripe setup, the three recurring tiers, checkout flow, customer portal, webhook contract, and how the audit chain captures every lifecycle event.
Billing & subscriptions
The boilerplate ships a complete subscription billing stack on top of Stripe :
three recurring tiers (free, pro, scale), one-shot credit packs,
checkout, the Stripe customer portal, an idempotent webhook handler, and
audit-chain emission on every lifecycle event.
Every feature below has a migration, a server action, a UI surface, and a
rollback path documented in CLAUDE.md.
The three tiers
Tier definitions live in src/config/billing.ts (subscriptionTiers). The
ordering is free < pro < scale (TIER_ORDER in src/libs/Tier.ts).
| Tier | Intended audience | Default checkout target |
|---|---|---|
| free | Self-serve trial, indie users | n/a (no Stripe price) |
| pro | Single team, Stripe-billed monthly / yearly | STRIPE_PRICE_PRO_* |
| scale | Multi-org, advanced features (LLM admin) | STRIPE_PRICE_SCALE_* |
Subscription state is persisted in the subscriptions table (migration
0008-subscriptions). The active org's tier is resolved at request time
via getEffectiveTier(orgId).
Required env vars
Paid tiers no-op without these — checkout buttons are visible but won't dispatch :
# Stripe API
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Webhook signature verification
STRIPE_WEBHOOK_SECRET=whsec_...
# Per-tier prices (create them in the Stripe dashboard, paste the price IDs)
STRIPE_PRICE_PRO_MONTHLY=price_...
STRIPE_PRICE_PRO_YEARLY=price_...
STRIPE_PRICE_SCALE_MONTHLY=price_...
STRIPE_PRICE_SCALE_YEARLY=price_...The pk_ key is intentionally NEXT_PUBLIC_ — the publishable key is safe
to ship in client bundles. Never put STRIPE_SECRET_KEY behind
NEXT_PUBLIC_*.
Configure prices in Stripe
- Sign in to the Stripe dashboard.
- Create a
Productfor each tier (Pro,Scale). - Add two recurring
Pricerows per product : one monthly, one yearly. The boilerplate computes the yearly discount from the price difference, so use whatever discount your business model wants — the UI labels are purely informational. - Copy the four
price_xxxIDs into.env.localagainst the fourSTRIPE_PRICE_*vars above.
Checkout flow
The /pricing page renders three plan cards from subscriptionTiers. The
"Upgrade to Pro" CTA posts to POST /api/stripe/checkout :
curl -X POST https://your-domain.example/api/stripe/checkout \
-H 'Content-Type: application/json' \
-b 'authjs.session-token=...' \
-d '{"tier":"pro","period":"monthly"}'The handler at src/app/api/stripe/checkout/route.ts :
- Resolves the user from the NextAuth session (Bearer not yet supported on this route).
- Reads
STRIPE_PRICE_<TIER>_<PERIOD>from the env. - Calls
stripe.checkout.sessions.create({ mode: 'subscription', … })with the user's email pre-filled and the active org id inmetadata. - Returns
{ url }. The client redirects withwindow.location.assign(url).
The success URL is /dashboard?checkout=success; cancel URL is /pricing.
Webhook contract
Stripe POSTs lifecycle events to /api/stripe/webhook. The handler at
src/app/api/stripe/webhook/route.ts :
- Verifies the signature against
STRIPE_WEBHOOK_SECRET. - Wraps the body in
Idempotency.handle()keyed byStripe-Event-Id— replays of the same event return the cached response instead of re-firing audit-chain rows. - Switches on
event.typeand upserts thesubscriptionsrow. - Emits the matching
audit_chainevent :customer.subscription.created/customer.subscription.updated→subscription.upgradedorsubscription.downgraded(compared against the prior row).customer.subscription.deleted→subscription.canceled.invoice.payment_succeeded→invoice.paid.invoice.payment_failed→invoice.failed.
The handler 200s gracefully when STRIPE_WEBHOOK_SECRET is unset (dev
safety) so a misconfigured local dev never blocks Stripe's retry queue.
Verify the webhook locally
Use the Stripe CLI to pipe events into your local dev server :
# Forward events to localhost
stripe listen --forward-to localhost:3000/api/stripe/webhook
# Trigger a synthetic subscription lifecycle
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
stripe trigger customer.subscription.deletedWatch the dev server logs and /dashboard/audit-chain — every webhook
should produce an audit_chain row that the chain verifier walks in
order.
Customer portal
Authenticated users with an active subscription can manage their billing
(update card, cancel, switch plan) via the Stripe customer portal at
/api/stripe/portal. The handler creates a Stripe billing portal session
scoped to the active org's customer_id and 302-redirects to the
returned URL.
The portal is configurable from the Stripe dashboard under
Settings → Billing → Customer portal. The minimum we recommend
enabling :
- Update payment method.
- Cancel subscription (at period end).
- View invoices.
- Switch plan (Pro ↔ Scale).
Tier gates in code
Gate a paid feature behind a minimum tier with the <TierGate> server
component or the requireTier / assertTier helpers in
src/libs/Tier.ts.
<TierGate> server component
import { TierGate } from '@/components/dashboard/TierGate';
export default async function PromptsAdminPage({ params }) {
return (
<TierGate min="pro" locale={params.locale}>
<PromptsAdminPanel />
</TierGate>
);
}When the active org is below pro, the component renders an upsell card
with a CTA to /pricing. Copy is i18n-driven via the Tier namespace in
src/locales/{en,fr}.json — forks override the wording without forking
the component.
requireTier (for server actions that prefer Result types)
import { requireTier } from '@/libs/Tier';
export async function createPrompt(input: CreatePromptInput) {
const gate = await requireTier(input.orgId, 'pro');
if (!gate.ok) {
return {
ok: false as const,
error: 'tier_required',
currentTier: gate.currentTier,
requiredTier: gate.requiredTier,
upgradeUrl: gate.upgradeUrl,
};
}
// … create the prompt …
}assertTier (for server actions that prefer exception flow)
import { assertTier, TierRequiredError } from '@/libs/Tier';
export async function exportPacket(orgId: string, packet: PacketInput) {
await assertTier(orgId, 'scale'); // throws TierRequiredError if below
// … export …
}One-shot credit packs
Coexist with subscriptions — see src/config/credits.ts. Pricing for
credit packs is denominated in integer cents (Money = Int). Float
math is banned in the codebase per the
core rules.
Add a new tier
- Append an entry to
subscriptionTiersinsrc/config/billing.tswith the copy + features. - Add the new id to
SubscriptionTierIdandsubscriptionTierEnuminsrc/models/Schema.ts. - Append the new tier id to
TIER_ORDERinsrc/libs/Tier.tsso the ordering stays consistent with<TierGate>. - Run
npm run db:generate— Drizzle emits anALTER TYPE … ADD VALUEmigration. - Wire the matching Stripe Price IDs via
STRIPE_PRICE_<TIER>_<PERIOD>env vars. - Add i18n strings (feature labels, explainer) under
PricingandDashboardSubscriptioninsrc/locales/{en,fr}.json.
Anti-patterns we ban
- Float math on money — every cents column is
INTEGERand every computation is integer arithmetic. - Webhook handlers that don't verify the signature — Stripe events
are signature-checked against
STRIPE_WEBHOOK_SECRETbefore any side-effect. - Webhook handlers that aren't idempotent — replays of the same
Stripe-Event-IdMUST be no-ops. Idempotency is enforced through the sharedIdempotencylibrary, not bespokeif (already_processed)checks. - Client-side checkout assembly — the price ID is resolved
server-side from
STRIPE_PRICE_*. The client only sendstier+period. This prevents tampering in the network tab.
What's next
- Compliance moat — the audit chain that captures every billing lifecycle event.
- Customizing the boilerplate — swap Stripe for Polar / Paddle via the payment-provider toggle.
Authentication overview
NextAuth credentials + 5 OAuth providers wired today, with 2FA, passkeys, API keys, and Better Auth as the additive Wave-A roadmap.
Email layer
Resend setup, env vars, the shipped templates, per-category notification preferences, HMAC unsubscribe links, and the dev no-op behavior that keeps local dev frictionless.