Docs / Billing / Plans & checkout
Plans & checkout
Keel ships three swappable billing providers behind one typed interface (lib/billing/), with Stripe as the default. Pick one with the BILLING_PROVIDER env var (stripe, lemonsqueezy, or polar, defaulting to stripe). Checkout and portal routes call into whichever one is active; the rest of this page uses Stripe.
How plans are defined
Plans live in lib/billing/plans.ts as a plain PLANS array, not in the provider's dashboard. Each Plan carries an id (solo, studio, agency), a display name, and monthlyCents/annualCents. Stripe builds its price inline from those cents at checkout time (price_data), so there are no pre-created Stripe Products to keep in sync. LemonSqueezy and Polar can't do inline pricing, so each plan also holds an optional per-provider product/variant id pulled from env vars; a missing one just means "not configured for that provider yet."
Credit top-up packs live in the same file as CREDIT_PACKS (1000 / 5000 / 10000 credits). Look up either with getPlan(id) or getCreditPack(id).
Starting a checkout
A team starts checkout by POSTing to /api/teams/[teamId]/billing/checkout. The route calls authorizeTeamAccess(teamId, "billing:write") first, then validates the body against a discriminated union on kind:
// Subscribe to a plan
await fetch(`/api/teams/${teamId}/billing/checkout`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "subscription", planId: "studio", interval: "month" }),
});
// Buy a credit pack
// body: { kind: "credits", packId: "5000" }
On success it returns { url }, the provider's hosted checkout link; redirect the browser there with window.location.href = url. Success and cancel URLs both point back to /app/settings/billing.
One subscription per team
If the team already has a live subscription with the active provider, a subscription checkout returns 409 with fallbackToPortal: true. Inline pricing means a second checkout would create a duplicate subscription (double billing), so plan changes go through the customer portal, not a fresh checkout.
That guard only sees a subscription once the provider's webhook has synced it into the subscriptions table, so it can't catch two checkout requests fired seconds apart, before either has a row to check against. The route closes that gap with a checkout_locks table: a subscription checkout inserts a row keyed on team_id before calling the provider, and a second request for the same team gets 409 while that row exists. The lock is released if the provider call itself fails, and self-expires after five minutes if a checkout is abandoned and no webhook ever arrives, so a team is never stuck unable to retry.
The customer portal
POST /api/teams/[teamId]/billing/portal to get a { url } for the provider's hosted portal, where a customer updates their card, changes plan, or downloads invoices. On Stripe this needs a one-time "Activate test link" click in the Dashboard; until then the route returns 502 with a clear message rather than crashing the billing page.
Cancel and resume
/api/teams/[teamId]/billing/cancel and /resume both POST with no body and return { ok: true }. Cancel sets cancel_at_period_end, so access continues until the period ends, never an immediate cutoff; resume clears it. Neither route writes to the database directly. The provider's webhook is the single source of truth for subscriptions.cancelAtPeriodEnd, so the local row updates when that customer.subscription.updated event arrives. If the provider rejects the call, both routes return an error with fallbackToPortal: true so the client can point the user at the portal instead.