Per-user credits (wallets)
Give every user of your SaaS a balance in your unit — credits, tokens, points — that odnoga debits automatically as they use AI, and that stops them when it runs out. No metering code, no cron job, no reconciliation.
This is the second way to monetise on odnoga. It composes with subscriptions: sell a plan that grants 500 credits a month and let overage be a top-up, or run credits alone with no subscription at all.
How it works
- You name the unit and set how many units one US dollar buys.
- Every request that carries an end-user id is converted at that rate from its real AI cost and debited from that user's wallet.
- When the balance reaches zero the request is refused with
402 end_user_exhausted— before any vendor is called, so an empty wallet costs you nothing. - While a request runs, its estimated cost is held from the wallet (the prompt plus your
max_tokens, or 1,024 output tokens when you set none). Ten requests sent at once by a user with one credit left can therefore not all run: the first takes the last credit, the rest get402 end_user_exhausted. When the request ends the hold goes and the real cost is debited. Image and audio requests check the wallet too.
You are still billed by odnoga for what your users actually consume. The wallet is your retail layer on top: the margin between what you charge for a credit and what the AI costs is yours.
Turning it on
Billing → End-user wallets, per workspace:
| Setting | What it decides |
|---|---|
| Enabled | Whether wallets are debited and enforced at all. |
| Unit name | What your users see — credits, tokens, runs. Cosmetic, but it appears in the error message. |
| Units per USD | Your retail rate. 1,000 units per USD means one credit is a tenth of a cent of AI cost. |
| Starting grant | Units a wallet receives the first time odnoga sees that end-user id. Your free tier. |
| Block at zero | On: an empty wallet returns 402. Off: the balance goes negative and nothing is refused — metering only. |
Set units_per_usd deliberately. It is the exchange rate between your pricing and your cost, and changing it later
does not restate balances already issued.
Topping up
Credit or debit a wallet whenever your own billing says so — after a Stripe payment succeeds, on a monthly plan refresh, or as a goodwill gesture:
// after your own payment webhook confirms the purchase
await fetch(`${ODNOGA_API}/airouter-mcp/…`, …) // or the MCP tool below
In practice most teams do this from an agent or a back-office screen rather than in code. Over MCP:
| Tool | Use |
|---|---|
wallets.settings.get | Read the unit, rate, grant and whether zero blocks. |
wallets.list | Every wallet with its balance; search by end-user id. |
wallets.adjust | Credit or debit one wallet, with a reason that lands in the ledger. |
wallets.entries | The ledger for one wallet — every debit, with the request behind it. |
wallets.adjust takes the external end-user id: the exact string your requests send in x-odnoga-end-user. A
wallet is created on first credit, so a typo funds a wallet nobody will ever spend from. Copy the id from
wallets.list rather than typing it.
Showing a balance
Your app can show each user what is left, straight from odnoga — no copy to keep in sync.
# From your server, with your API key: any of your end users
curl https://api.odnoga.com/v1/balance?end_user=user_123 -H "Authorization: Bearer $ODNOGA_API_KEY"
# From the browser, with that user's end-user token: only their own wallet
curl https://api.odnoga.com/v1/balance -H "Authorization: Bearer $EUT"
{
"object": "balance",
"end_user": { "id": "user_123", "wallet": true, "unit": "credits", "units_per_usd": 1000,
"balance_units": 240, "held_units": 8, "available_units": 232, "blocks_at_zero": true },
"organisation": { "pays_from_credit": true, "currency": "usd",
"credit_usd": 41.2, "held_usd": 0.03, "available_usd": 41.17 }
}
held_units is what requests still running have set aside; available_units is what a new request can use. An
end-user token never sees organisation. The history — grants, top-ups, every spend with its request id — is at
GET /v1/ledger?end_user=user_123 (newest first; limit up to 200, before=<ISO time> for the next page).
Handling the refusal
402 end_user_exhausted is a normal, expected outcome — not an incident. Catch it and show your own upgrade path:
const r = await openai.chat.completions.create({ … });
// on 402 with code end_user_exhausted:
// → render your "you're out of credits" screen, link to your checkout
The message names the unit you configured, so it reads as your product rather than as an odnoga error.
What wallets do not do
- They do not charge anyone. Money is collected by your Stripe account through end-user billing, or by whatever you already use. A wallet is a balance, not a payment method.
- They do not apply to traffic with no end-user id. An unattributed request is billed to the workspace and passes through, whatever any wallet says — which is one more reason to send the header on every call.
- They are per workspace. The same end user in two workspaces has two wallets.