Stripe
Billing. Subscription checkout + webhook-driven plan state.
Files
src/lib/billing/stripe.ts— client + helpers.src/lib/billing/plans.ts— plan matrix.src/app/api/internal/billing/route.ts— checkout session create.src/app/api/webhooks/stripe/route.ts— inbound events.
Client
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2026-03-25.dahlia",
});
API version pinned — upgrades should be intentional.
Environment
STRIPE_SECRET_KEY— secret key.STRIPE_WEBHOOK_SECRET— webhook signing secret.STRIPE_PRO_PRICE_ID—price_...for the Pro plan (referenced in checkout; required at runtime for upgrade).
Checkout flow
Webhook events handled
| Event | Effect |
|---|---|
checkout.session.completed |
Upgrade org to pro |
customer.subscription.deleted |
Downgrade to free |
invoice.payment_failed |
console.warn (TODO: dunning) |
Other events are ignored — send Stripe dashboard → webhook configuration should select only the above for efficiency.
Webhook verification
stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET)
Rejects body with invalid signature as 400.
Local webhook testing
stripe listen --forward-to localhost:3000/api/webhooks/stripe
The CLI prints a signing secret — paste into .env.local as STRIPE_WEBHOOK_SECRET.
Metadata
Checkout sessions include metadata.orgId for downstream attribution. The webhook handler doesn't currently use it — it matches by stripe_customer_id instead.