GenerateSaaS

Metering your features

Debit credits from any server-side path - gate refusable work, settle incurred cost, stay idempotent, and bound concurrent exposure.

From any server-side path - a Hono route, background job, webhook - derive the price, gate work that can still be refused, perform it, then settle its full cost:

import { Billing, costToCredits } from "@repo/payments";

const billing = await Billing(session); // or BillingForWorkspace(orgId, userId) off-request
const credits = costToCredits(0.4, "seo"); // this lookup costs you $0.40, margined as "seo"

if (!billing.hasCreditsFor(credits)) {
	return c.json({ error: "Insufficient credits" }, 402);
}

const data = await fetchSerpResults(query); // your paid upstream call

await billing.settleCredits(credits, {
	source: "system",
	type: "credit_usage",
	feature: "seo.serp-lookup",
	description: "SERP lookup",
	metadata: { cost: { usd: 0.4, category: "seo" } } // what the vendor charged you
}, {
	cause: "usage",
	idempotencyKey: `serp_${runId}`
});
OperationBehaviorUse when
removeCreditsFull debit or INSUFFICIENT_CREDITS; never overdraws.Work can still be refused.
settleCreditsFull signed debit; may create debt.Usage, refund, or chargeback already happened.
  • Idempotency: pass a globally unique run id created once for the logical operation and reused on retries, so a retry resolves without settling twice.
  • Debt: a negative balance repays through normal signed addition (adding 12 to -10 leaves 2), and hasCreditsFor keeps new metered work gated while debt exists.
  • Feature tags: tag every usage debit with a feature id ("ai.chat", "seo.serp-lookup"). It lands on the billing log row, filters queryBillingLogs({ feature }), powers sumCreditUsageByFeature(...), and labels the row in the transactions UI.
  • Vendor cost: stamp metadata.cost with the vendor's USD charge before markup and its markup category. The admin finance dashboard reads it to set spend against revenue; a row without it is excluded, so leave the stamp off rather than sending { usd: 0 } for a cost you do not know.
  • One line per run: spread billingLogRunMetadata({ runId, threadId? }) into metadata on every row one multi-step operation settles, and the user's transaction history shows them as one line - amount summed exactly, steps on expand, pages counted in lines. The shipped AI chat and automation runs do this; a row without it, or a run that settled only one row, stays its own line.

Token-billed streaming only knows its cost once the run ends, so the shipped AI surfaces add a pre-flight estimate gate and a mid-run budget stop. An estimate miss or a concurrent run can still expose debt rather than hide incurred usage.

Every model call is gated twice, and the second gate is the one that bounds the loss:

GateWhereWhat it asks
Pre-flightthe route (chat, /summarize, automations)Can the balance cover one estimated turn? A clean 402 before anything runs.
Per callthe credits middleware, before the provider is reachedCan the balance cover THIS call's input plus a short answer? If not, the call is refused.

The per-call gate prices the prompt and tool schemas the call actually carries, so a dust balance cannot buy a maximum-context prompt whose input alone outruns it. What is left after the input is what the answer may spend - the output allowance is never budgeted against credits the prompt has already claimed.

A balance can be non-empty and still insufficient for the message in front of it. That refusal happens before any model call, so nothing streamed and nothing was billed - surfaces say the message costs more than the balance has left, not that a run stopped. The route gate normally catches it first and answers 402; the per-call gate is the backstop for what the route could not measure.

Concurrency caps

Each live run can spend up to a balance-worth before settlement catches up, so exposure scales with how many runs draw on the same balance. A chat turn counts while it streams and a cloud automation while it executes. A BYOK chat turn never counts; a cloud automation counts on any key, since it still spends server time and your tool keys.

LimitWhereDefaultWhat it bounds
maxConcurrentRunsthe plan (pricing.ts)free 2, starter 5, pro 10Runs open against one balance across everyone drawing on it. The tier's throughput promise and its exposure bound.
perUserconfig.ai.runLimits4Runs one user may hold open. Keeps multi-tab work smooth and stops one member starving the team.
perPayerconfig.ai.runLimits16The per-balance fallback for a plan that names no maxConcurrentRuns.

On a personal (no-organization) account the payer is the user, so the tighter of perUser and the plan's ceiling binds.

Refusals differ by surface. A chat turn answers 429 with a message naming how many runs are in flight. A due cloud automation waits: it defers to the next 5-minute sweep, burning zero credits, writing zero usage, and recording no failure on the row.

Raise perPayer for larger teams, lower it to tighten worst-case debt.

On this page