golemkit
← All posts

July 21, 2026 · 5 min read

Billing AI products by usage: the credit-ledger pattern

Subscriptions are a bad fit for AI products. Your costs scale with tokens, not with calendar months — so a $29/mo plan either overcharges light users or loses money on heavy ones. This boilerplate ships with usage-based billing instead: users buy credit packs, and every AI call spends credits in proportion to the tokens it actually consumed.

Two tables, one invariant

The billing module keeps credits in two tables. user_credits holds the current balance — one row per user, fast to read. credit_transactions is an append-only ledger: every grant, purchase, and deduction, with a reason. Both are updated in a single database transaction, so the balance is always the sum of the ledger.

purchase   +500   "Purchased 500 credits"
usage        -3   "Chat: 2,841 tokens (claude-sonnet-5)"
usage        -1   "RAG: 912 tokens"

The billable endpoint pattern

Every metered endpoint follows the same five steps, and the streaming chat route is the reference implementation:

  1. Authenticate the session — reject anonymous requests with 401.
  2. Rate limit per user — 429 when exceeded.
  3. Pre-check the balance — 402 when empty, before paying the model provider.
  4. Stream the model response.
  5. On finish, convert actual token usage to credits and charge — spending at most what the user has.

The important detail is the order: the cheap checks run before the expensive call, and the charge is based on real usage reported by the model, not an estimate.

Why retries can't double-grant

Payment webhooks are delivered at-least-once, so the grant path must be idempotent. Each purchase stores the provider's payment reference with a unique constraint — a retried webhook that tries to grant the same purchase again becomes a no-op at the database level. No distributed locks, no dedup cache; the constraint is the guarantee.

Race-safety in one query

Two concurrent requests can't overspend a balance, because deduction is a single conditional update: set balance = balance - N where balance >= N. If the row doesn't match, the deduction simply didn't happen and the caller sees a failure — no read-then-write window to race through.

The whole system is ~200 lines across src/modules/billing. Point your agent at the chat route and ask it to add another billable endpoint — that's the pattern it will find and copy.