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}`
});| Operation | Behavior | Use when |
|---|---|---|
removeCredits | Full debit or INSUFFICIENT_CREDITS; never overdraws. | Work can still be refused. |
settleCredits | Full 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
12to-10leaves2), andhasCreditsForkeeps new metered work gated while debt exists. - Feature tags: tag every usage debit with a
featureid ("ai.chat","seo.serp-lookup"). It lands on the billing log row, filtersqueryBillingLogs({ feature }), powerssumCreditUsageByFeature(...), and labels the row in the transactions UI. - Vendor cost: stamp
metadata.costwith the vendor's USD charge before markup and its markupcategory. 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? })intometadataon 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:
| Gate | Where | What it asks |
|---|---|---|
| Pre-flight | the route (chat, /summarize, automations) | Can the balance cover one estimated turn? A clean 402 before anything runs. |
| Per call | the credits middleware, before the provider is reached | Can 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.
| Limit | Where | Default | What it bounds |
|---|---|---|---|
maxConcurrentRuns | the plan (pricing.ts) | free 2, starter 5, pro 10 | Runs open against one balance across everyone drawing on it. The tier's throughput promise and its exposure bound. |
perUser | config.ai.runLimits | 4 | Runs one user may hold open. Keeps multi-tab work smooth and stops one member starving the team. |
perPayer | config.ai.runLimits | 16 | The 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.
Credits
Meter any paid feature with usage-based credits - per-plan grants, recurring re-grants, cost-priced debits, custom top-ups, and refund handling.
Pricing and margins
costToCredits economics - creditsPerUsd, markups per category, the quantization step, the minimum charge, and the plan-solvency check.