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_IDprice_... for the Pro plan (referenced in checkout; required at runtime for upgrade).

Checkout flow

See ../features/billing.md.

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.