Users and spend
End-user credits: charge your users for agent spend on camelRun
Give each of your users a balance and daily free allowances, enforced on every model call by camelRun, with webhooks when someone runs low or runs out.
View as Markdown · Docs index for coding agents
End-user credits let you charge your own users for what their agents spend. camelRun keeps a balance for each person your app serves, gives each a daily free allowance per part of your product, enforces both on every model call, and tells you when someone runs low or runs out. You keep the money side: your payment provider, your pricing page, your emails. After a payment, you tell camelRun "add $10 to this person".
Everything a person causes counts against one balance: every agent that acts for them, their stateless runs, transcriptions, images, web searches and the rest. You still pay camelRun for raw usage as before (see pricing). End-user balances are your ledger of your users, and camelRun never moves money for them.
The model
- End user: the
subjectof an agent (set when you upsert it, see multi-user apps) or of a call (POST /v1/runs,/v1/transcriptions,/v1/images). An agent's sub-agents act for its subject too. Work with no subject is not metered: you pay for it, as today. - Bucket: the part of your product that spends, such as
builderorbot:42. An agent spends from itsbucket; a stateless run or call names its own. A prompt sent with your API key (POST /v1/agents/{id}/prompt) may name another; an agent's own token can't. A run that names none (a schedule's, a sub-agent's notice, a channel's message, a voice session'sask_agent, or one you send without) spends from the agent's bucket, else the bucket your latest run of the agent named, elsedefault. - Daily allowance: free spend per bucket per UTC day, spent first. Set it by
bucket name, by prefix (
"bot:*") or for every bucket ("*"). A bucket takes its own name, else the longest prefix it starts with, else*. You can override allowances per person. Allowances count raw cost: what the call costs you. - Balance: paid credit, spent once the allowance is used. Grants (after a payment) add to it; refunds and adjustments take from it.
- Markup: added to the raw cost of paid spend before it is charged
(
markupBps: 2500 is +25%). The ledger records both. - Daily limit: the most a person's paid spend may be per UTC day, for everyone or per person.
- Bucket limits: the most one bucket may charge a person's balance per UTC
day, keyed like allowances, for everyone or per person. A bot at
"bot:*": 2_000_000spends its allowance, then at most $2 of the balance a day, however much the person holds. At0it spends its allowance only. To list the only buckets that may spend paid credit, give"*": 0and a limit for each:{ "*": 0, builder: 20_000_000, "bot:*": 2_000_000 }. No other bucket,defaultincluded, then ever charges anyone's balance.
All amounts are integer micro-USD: 1 USD = 1,000,000.
A call costs a person what it cost you. On camelRun's keys that is what you are
charged for it: the provider's price, plus the cost of funding OpenRouter credit
on OpenRouter calls (about 5.5%). On your own keys it is the provider's reported
cost, else the catalog's price for its tokens (cache reads and writes included),
as usage.recorded shows. The markup is on top. A model with no price (one of
your own models declared without pricing) is never
free to them: it is charged at the catalog's price for the model, else a
mid-range price ($3 in, $15 out per million tokens). Declare its pricing to
charge your own.
Changing an allowance or a limit never resets what was spent today. Spend is kept per day and bucket, and the allowance is only the cap: raise a bucket from $40 to $60 mid-day and the person has $20 more, not $60.
Turn it on
import { Agents } from "@camelai/run";
const agents = new Agents();
await agents.endUsers.configure({
enabled: true,
allowances: { builder: 50_000_000, "bot:*": 1_000_000 }, // $50 a day for the builder, $1 a day per bot
markupBps: 2_500, // paid spend at cost +25%
dailyLimit: 20_000_000, // at most $20 of paid spend a day
bucketLimits: { "bot:*": 2_000_000 }, // and at most $2 of it per bot
lowBalance: 2_000_000, // end_user.balance.low below $2
allowanceWarningPercent: 80, // end_user.allowance.low at 80% of a bucket's allowance
});The REST call is PUT /v1/end-user-settings. Fields left out keep their values,
but allowances and bucketLimits, when sent, replace the whole map. To change
some keys and keep the rest, use PATCH /v1/end-user-settings
(endUsers.amend, Python end_users.amend): only what you send changes, maps
merge key by key, and null removes a key. A person's own overrides merge the
same way (endUsers.update).
Several writers can change the settings at once (each server of yours as it
starts, an admin tool, a script). Each write changes only the fields it sends,
and a PATCH merges its keys into the map as it stands, so writes to different
fields or keys all keep their changes. Of two writes to the same field or key,
the later wins. Give each writer its own fields and keys and have it PATCH
only those.
Example: pay as you go
A person buys credit with Stripe; their builder agent and their bots spend it.
// 1. Their agents act for them, each from its bucket.
const builder = await agents.upsert(`builder-${user.id}`, { subject: user.id, bucket: "builder", definition: BUILDER });
const helper = await agents.upsert(`bot-${botId}`, { subject: user.id, bucket: `bot:${botId}`, definition: BOT });
// 2. One-off model calls in their bot's code spend from the same bucket.
const answer = await agents.run({ input: prompt, subject: user.id, bucket: `bot:${botId}` });
// 3. A payment lands: credit it, once per payment (a retried webhook posts nothing twice).
app.post("/stripe/webhook", async (req, res) => {
const event = stripe.webhooks.constructEvent(req.body, req.headers["stripe-signature"], secret);
if (event.type === "payment_intent.succeeded") {
const payment = event.data.object;
await agents.endUsers.grant(payment.metadata.userId, { amount: payment.amount * 10_000, key: payment.id, reference: `Stripe ${payment.id}` });
}
res.sendStatus(200);
});
// 4. Their credit page: where they stand, what they bought, and what their credit went to each day.
const person = await agents.endUsers.get(user.id, { buckets: ["builder"] });
// person.balance, person.available, person.today.buckets[0].allowanceLeft
const purchases = await agents.endUsers.ledger(user.id, { kind: ["grant", "refund", "adjustment"], limit: 20 });
const { days, next } = await agents.endUsers.days(user.id, { limit: 7 });
// days[0]: { day: "…", freeRaw, paidCharged, writtenOff, granted, refunded, adjusted, entries, buckets: [{ bucket: "bot:42", freeRaw, paidCharged, writtenOff }] }In Python:
await agents.end_users.grant(user_id, amount=amount_cents * 10_000, key=payment_id, reference=f"Stripe {payment_id}")
person = await agents.end_users.get(user_id, buckets=["builder"])
purchases = await agents.end_users.ledger(user_id, kind=["grant", "refund", "adjustment"], limit=20)
days = await agents.end_users.days(user_id, limit=7)A credit page reads no usage rows: the ledger's kind filter leaves them out,
and days gives each UTC day's totals by bucket in one request.
For a refund you make in Stripe, endUsers.refund(id, { amount, key }) takes the
credit back. It can take at most the balance that calls in flight don't hold;
past that it is a 409 INSUFFICIENT_BALANCE, with refundable. For anything
else, endUsers.adjust(id, { amount, key }) takes a signed amount. Grants,
refunds and adjustments are idempotent by key: the same key and entry again
returns the first, and the same key for a different entry is a 409.
To charge a saved card when someone runs low, see auto top-up.
When a person runs out
camelRun holds credit for each model call before making it: its input and up to 2,048 tokens of output, from the bucket's allowance first, then the balance. A longer reply holds more as it streams. Holds are taken one at a time per person, so calls in parallel (sub-agents, several of their agents at once) never each see the whole balance.
When nothing more can be held:
- A new run is refused with
402and codeEND_USER_CREDIT, withendUser,bucketandreasonin the body. The SDKs throwAgentErrorwith the same fields (reason,bucket,endUser; Pythonend_user). - A queued run refused as it starts fails with code
end_user_credit,reason,bucketandendUser. - A run already going stops where the credit ends, even mid-reply. What
streamed is kept, and the run fails with code
end_user_credit,stopped: "spend_limit",limit: "end_user", areason, thebucketand theendUser. It is never retried. - The
run.failedwebhook carriescode,limit,reason,bucketandendUsertoo, andvoice.session.endeditsendUserReasonandbucket. - Transcriptions, speech and images are refused before they are made, with the same 402.
reason is one of:
reason | Meaning |
|---|---|
balance | No allowance left in the bucket today, and no balance |
daily_limit | The person's paid spend reached the daily limit |
bucket_limit | The bucket's paid spend reached its own limit. The person may still have a balance for other buckets; a bucket limited to 0 spends only its allowance |
endUserCredit (Python end_user_credit) reads these from a thrown error, a
failed run or its error, so one place handles every path:
import { endUserCredit } from "@camelai/run";
try {
const run = await agent.run(text, { throwOnError: false });
const credit = endUserCredit(run);
if (credit) notify(user, credit.reason);
} catch (error) {
const credit = endUserCredit(error); // a 402 at submit
if (!credit) throw error;
notify(user, credit.reason);
}
function notify(user: string, reason?: string) {
// "daily_limit" | "bucket_limit" | "balance"; a reason added later: a credit refusal of no particular kind.
}Branch on code and reason, never on the message: its words are for people
and may change.
Costs past a call's hold are written off
Usage never takes a balance below zero, and never takes paid spend past the daily limit or a bucket's limit. A call can still cost more than it held:
- output a provider bills without streaming it (hidden reasoning tokens);
- what streams while the last credit check is answered (milliseconds of output);
- the gap between the input estimate and the real input (a provider cache that expired, a provider's own price at the time);
- a call cut off by a failure: its hold lapses after 10 minutes, and if the provider reports its cost later, it is settled then.
When it settles, the person is charged at most what is left for it: the balance,
the daily limit and the bucket's limit, less what their other calls in flight
hold. The rest is written off. You bear it (your camelRun usage is the
call's full cost, as always), and it is never charged to the person later, even
after they buy credit. Each usage entry shows it as writtenOff,
GET /v1/end-users/{id} totals it per bucket for today
(today.buckets[].writtenOff), and GET /v1/end-users/{id}/days per bucket and
day, all in raw micro-USD. The limit that capped the charge fires
end_user.credit.exhausted as a refused call would, and the person's next call
is refused.
A cost corrected down later takes back the write-off first, then refunds what the balance paid, then the allowance. A cost corrected up is capped the same way, by the limits of the day the call began and the balance as it is then.
Webhooks
Subscribe an endpoint (POST /v1/webhooks, see webhooks) to:
| Type | When |
|---|---|
end_user.balance.low | A balance went below the person's threshold: their own lowBalance (PATCH /v1/end-users/{id}), else yours. See when balance events fire |
end_user.credit.exhausted | reason: "allowance": a bucket's allowance for the day is used up (the balance may still pay; once per person, bucket and day). "balance": the balance reached zero, or a call was refused for it. "daily_limit": the daily limit was reached (once a day). "bucket_limit": a bucket's own limit was reached (once per person, bucket and day) |
end_user.credit.granted, .refunded, .adjusted | You posted a grant, refund or adjustment, once per entry (a replayed key sends nothing): {endUser, entryId, kind, amount, key, reference, metadata, balance, previousBalance}, with amount signed as in the ledger. For receipts, and for a second service or a mirror of balances |
end_user.allowance.low | A person's spend in a bucket passed allowanceWarningPercent (1 to 100, off by default) of its daily allowance, once per person, bucket and day: {endUser, bucket, day, allowance, allowanceSpent, percent} |
They are signed and retried like every webhook, and written in the same transaction as the change that caused them.
When balance events fire
end_user.balance.low and end_user.credit.exhausted with reason: "balance"
each carry a cause: what moved the balance.
"cause": {
"kind": "usage", // usage | usage_correction | grant | refund | adjustment | refusal | threshold_changed
"entryId": 1234, // the ledger entry; none for refusal and threshold_changed
"entryKey": "dp_123", // your key, for grant, refund and adjustment
"requestId": "…", // usage: the run's request, when there is one
"bucket": "bot:42"
}usage_correctionis a call's cost posted again, corrected: a provider's final figure, which can come long after the call.refusalis a call refused with no balance left. It has no ledger entry.- A person's entry ids grow, so
entryIdputs their events in order, which delivery may not.
The rules:
balance.lowfires on a debit (usage or a negative adjustment) that takes the balance from at or above the threshold to below it. Staying below fires nothing more until one of the next rules applies.credit.exhausted(balance) fires on a debit that takes the balance from above zero to zero or below, and on a call refused with no balance left (cause.kind: "refusal"), once until the balance is back above zero or re-armed.- A grant or positive adjustment that leaves the balance short re-arms the
event: the next debit fires it again. With a $10 threshold, a $5 top-up that
leaves someone at $6 makes their next debit fire
balance.lowagain. A grant that leaves someone at or below zero makes their next debit or refused call firecredit.exhaustedagain. - Any move that leaves the balance past the line (at or above the threshold, above zero) resets the event, including a cost corrected down: the next crossing fires.
- Setting a threshold fires
balance.lowonce, withcause.kind: "threshold_changed", for each person under it who hasn't been told since they were last above it (or was re-armed since). A person's own threshold applies to them alone. Your account-widelowBalanceapplies to everyone without their own who has ever been credited. Setting the same value again fires nothing. - Refunds are silent. A refund fires neither event (no one should be topped up because you gave money back), and counts as told for each line the balance is short of, so neither fires until a grant or a positive adjustment.
- Someone never credited (who only spends free allowances) isn't re-armed by anything, and your account-wide threshold never tells them their balance is low.
The ledger
Every movement of a person's money is an entry, newest first:
kind | amount | Also |
|---|---|---|
grant, refund, adjustment | What you posted | Your key, reference and metadata |
usage | What the balance paid (negative); 0 when the allowance covered it all | raw (the call's cost), allowance (what the allowance covered), writtenOff (the cost past what was left for it, not charged), bucket, and metadata naming the call (response, compaction, transcription, image, web_search…), its agent and run |
A call whose cost is corrected later has another usage entry for the
difference. balanceAfter is the balance after each entry.
Narrow it with kind (any of grant, refund, adjustment, usage;
comma-separated in the API), from and to (a time range on createdAt, to
exclusive: epoch milliseconds, a UTC day or an ISO 8601 time), bucket, and
moved=true (only entries that moved the balance, leaving out usage the
allowance paid in full). They combine with each other and with before and
limit.
Per day
GET /v1/end-users/{id}/days (endUsers.days(id, { from, to, bucket, limit }))
gives a person's UTC days, newest first, limit days a page (default 31, at most
100; pass next as to for the next page). from and to are UTC days
(YYYY-MM-DD, to exclusive), at most 366 apart, by default the 366 days up to
today. Days with no spend and no entries are left out. bucket keeps that
bucket's spend alone, without entries (entries belong to no bucket).
A call counts on the UTC day it began (its usage entry's metadata.day), so near
midnight that can differ from its entry's createdAt. An entry you post counts
on the day you posted it.
Units
Amounts are integer micro-USD. Some are raw cost (what a call cost you); others are charged, in the balance's units (raw cost with your markup).
| Field | Unit |
|---|---|
allowances, allowance, allowanceSpent, allowanceLeft | Raw |
A usage entry's raw, allowance and writtenOff; a bucket's or a day's writtenOff | Raw |
balance, held, available, dailyLimit, bucketLimits, paidLimit, lowBalance | Charged |
paidSpent, any entry's amount and balanceAfter | Charged |
A day's freeRaw (what allowances paid) | Raw |
A day's paidCharged (what the balance paid), granted, refunded, adjusted | Charged |
A bucket's held today adds the allowance's part of holds (raw) to the
balance's (charged). Read available and allowanceLeft for what is left.
API
| Call | SDK (TypeScript / Python) |
|---|---|
GET, PUT, PATCH /v1/end-user-settings | endUsers.settings(), endUsers.configure({...}), endUsers.amend({...}) / end_users.settings(), end_users.configure(...), end_users.amend(...) |
GET /v1/end-users | endUsers.list({ after, limit, belowLowBalance }): belowLowBalance lists only those under their threshold |
GET /v1/end-users/{id}?buckets= | endUsers.get(id, { buckets }) |
PATCH /v1/end-users/{id} | endUsers.update(id, { allowances, bucketLimits, dailyLimit, lowBalance }): a person's own allowances and bucket limits (merged; null removes one), daily limit and low-balance threshold (null: yours) |
GET /v1/end-users/{id}/ledger?kind=&from=&to=&bucket=&moved= | endUsers.ledger(id, { before, limit, kind, from, to, bucket, moved }) / end_users.ledger(id, before=, limit=, kind=, from_=, to=, bucket=, moved=) |
GET /v1/end-users/{id}/days?from=&to=&bucket=&limit= | endUsers.days(id, { from, to, bucket, limit }) / end_users.days(id, from_=, to=, bucket=, limit=) |
POST /v1/end-users/{id}/entries | endUsers.grant, refund, adjust |
bucket is taken by agent upserts and configuration, prompts sent with your API
key, POST /v1/runs, /v1/transcriptions and /v1/images.