Keel

Docs / Billing / Credit system

Credit system

Keel meters AI usage with a per-team credit balance. Two tables back it, both in lib/db/schema/credits.ts: credits holds one row per team (balance, the live number that gates the next generation), and usage_events is the append-only audit trail of what consumed credits, which the billing page's "used this month" figure sums from.

Deducting on an AI call

lib/ai/credits.ts splits the deduction into three functions instead of one charge-on-completion call: reserveAiCredits() decrements the balance before a generation starts, recordAiCreditUsage() writes the audit row once it finishes, and refundAiCredits() gives the reservation back if it fails.

ts
export async function reserveAiCredits(params: { teamId: string; cost: number }) {
  const charged = await db
    .update(credits)
    .set({ balance: sql`${credits.balance} - ${params.cost}` })
    .where(and(eq(credits.teamId, params.teamId), gte(credits.balance, params.cost)))
    .returning({ teamId: credits.teamId });

  return charged.length > 0;
}

The decrement is atomic and conditional: balance - cost happens in SQL, and the WHERE balance >= cost clause means the row only changes if it still holds enough credits. Two concurrent requests can't both win this update, so the balance can never race below zero, and because this runs before the model is called, a request that loses the race never triggers a paid generation at all.

Why not charge after the generation finishes

Charging only once a reply finishes streaming means the expensive part already happened by the time the balance is checked for real. Reserving first turns the balance check back into the actual gate on provider spend, not just a UX nicety. See Usage & credits for how the chat route wires this into onFinish/onError/onAbort.

Topping up

A team buys more credits through the same checkout route as plans, /api/teams/[teamId]/billing/checkout, with a credits body:

ts
await fetch(`/api/teams/${teamId}/billing/checkout`, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ kind: "credits", packId: "5000" }),
});

This is a one-time Stripe payment (mode: "payment"), not a subscription. Nothing is granted at checkout. When Stripe fires checkout.session.completed, grantCredits() in lib/billing/sync.ts records the purchase in credit_purchases (its unique provider_checkout_id prevents double-crediting a replayed event), then does an atomic balance + N increment, creating the credits row on first purchase via onConflictDoUpdate.

The database floor

credits.balance also carries a DB-level CHECK constraint, credits_balance_nonneg (balance >= 0), defined on the table. The application-level WHERE balance >= cost already prevents an overdraw, so this constraint is defense in depth: any future writer that decrements without going through reserveAiCredits() and drives the balance negative fails at the database instead of silently going below zero.

Read the current balance with GET /api/teams/[teamId]/credits, a lightweight route for surfaces like the chat composer that only need the number. The full billing page uses GET /api/teams/[teamId]/billing, which also returns this month's usage and provider payment-method/invoice data.

Edit this page on GitHub →Last updated Jul 22, 2026