Stripe
Billing. Subscription checkout + webhook-driven plan state.
Files
src/lib/billing/stripe.ts— client + helpers, includinglistBillableSubscriptions.src/lib/billing/plans.ts— plan matrix.src/lib/billing/settlement.ts—settleFinalUsage, the invoice that bills a leaving customer's tail.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
Five, not three. The three subscription events share one handler.
| Event | Effect |
|---|---|
checkout.session.completed |
Apply the tier from metadata.plan (payg, else pro) |
customer.subscription.created |
Re-derive the tier from the customer's live subscriptions; check for a period settlement already closed |
customer.subscription.updated |
Same — this is how a move between Pro and Pay as you go arrives |
customer.subscription.deleted |
Settle the final usage, then re-derive (free when nothing is left) |
invoice.payment_failed |
logError("stripe.payment_failed") (TODO: dunning) |
The three subscription events never trust their own status snapshot: Stripe does
not guarantee ordering, so the handler re-lists the customer's subscriptions and
prices the org off whatever is live right now. A stale updated arriving after
deleted therefore cannot restore a paid tier.
Deliveries are deduped on event.id through Upstash Redis, and the key is a
claim released on failure — a 500 has to be re-delivered, not swallowed as a
duplicate.
Other events are ignored — the Stripe dashboard → webhook configuration should select only the above for efficiency.
Final usage settlement
Usage is metered a month behind, by the report-overage cron, through a metered
price on the customer's live subscription. A cancellation ends that: the
cron answers no_subscription, and everything sent between the 1st and the
cancel would go unbilled.
So customer.subscription.deleted calls settleFinalUsage — and only when the
re-listed subscriptions come back empty, since a customer moving between tiers
still has one for the cron to bill through. It invoices the tail directly (an
invoice needs no subscription), at the rate on the channel's configured overage
price, and closes the same overage_reports ledger rows the cron claims, so
neither can bill a period the other already did. Failure is logged and the
downgrade still lands.
It runs in after(), so none of those round-trips sit in front of the 200 —
this endpoint is shared, and a handler slow enough to time out takes
subscription.deleted and invoice.payment_failed down for every other
customer too. The route sets maxDuration = 300 all the same: after() still
runs inside the invocation, and a kill between a ledger claim and its close
strands the row in pending, which neither writer will resolve on its own.
customer.subscription.created runs the mirror check: a customer resubscribing
into a period settlement already closed is billed for the rest of that month by
nobody, so it raises billing.settlement.resumed_after_settlement.
Full contract, including the org-delete path that reaches the same function: ../features/billing.md.
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 carry metadata.orgId and metadata.plan. The handler
matches the org by stripe_customer_id, not by orgId — but it does read
plan, because checkout.session.completed can land before the new
subscription is listable as active, and re-deriving the tier there would write
the customer onto the wrong one.