API
Wallets
Your users' money, and your own. The first seven are the ledger you keep for your customers; the last is your tenant's own balance.
Everything is micro-USD
A wallet's balance, a credit, an adjustment and every statement row are whole micro-USD: 5000000 is five dollars. Divide by 1,000,000 to render. The field is called micros rather than amount on purpose — your tenant's own balance is reported in TOKENS on this same API, and two keys that cannot be confused is cheaper than one whose unit a reader has to know.
A wallet is keyed on (project, your user id)
So ada in one project is a different person with a different balance from ada in another, and a credential issued for one project cannot touch another's wallets. The id is whatever you already call this person — an email, a UUID, your own primary key. It only has to be unique within one project, and it may not be * or contain : or /.
The SDK splits reading from writing, and that is load-bearing
elan.wallets reads — list, get, statement — and elan.credit writes. The read object issues no non-GET at all and a test asserts it, so a read-only credential can be handed wallets and a future write cannot quietly land on it. Reading your own figures is elan.usage.
Creating is idempotent; crediting is not
One wallet per user is enforced, so a repeat create is not a second balance — it answers 200 instead of 201 and is free. A repeat CREDIT issues the credit twice, and ref is the only thing that will tell you so afterwards.
The samples use $API and $TOKEN, which the API overview sets. What your agents consumed, and what the platform charged you, is on the usage page.
Create a wallet
POST/v1/projects/{project}/walletsdeploy-service
Gives one of your users a balance. Safe to call on every sign-in.
201 the first time, 200 afterwards
Both are success and both return the wallet. A backend that calls this whenever somebody signs in is the expected pattern rather than something to code around, and the repeat is not billed.
USD is the only currency accepted
Every price on this platform is quoted in US cents and nothing converts between currencies, so a wallet labeled anything else would be charged incorrectly. The field exists so conversion can arrive later without a migration; until then you can ignore it.
curl -X POST "$API/v1/projects/agents/wallets" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"external_user_id": "ada", "display_label": "Ada Lovelace"}'Body
| Field | Type | Description | |
|---|---|---|---|
| external_user_id | string | required | Whatever you already call this person. Unique within the project. |
| display_label | string | optional | A human-readable name, for your own console and statements. |
| currency | string | optional | ISO 4217. Defaults to USD, which is the only accepted value. |
Responses
| Status | Meaning |
|---|---|
| 201 | Created. |
| 200 | It already existed. Not an error, and not billed. |
| 400 | The id or the currency was refused — an empty id, *, one containing : or /, or a currency other than USD. The body names which. |
| 403 | This credential cannot write — it needs Can write — or it is scoped to another project. |
| 404 | No such project in your tenant. |
| 502 | The ledger is unreachable. Nothing was written; retry. |
Response body
{
"external_user_id": "ada",
"project_slug": "agents",
"display_label": "Ada Lovelace",
"status": "active",
"currency": "USD",
"balance_micros": 0,
"held_micros": 0,
"available_micros": 0
}List your users
GET/v1/projects/{project}/walletsdeploy-service
Every wallet in the project and what each one has left.
The total is in the body, not a header
{wallets, total}, so a paged table can show both its rows and how many there are. A header would be unreadable to any client that returns parsed JSON rather than the response object.
curl "$API/v1/projects/agents/wallets?limit=50&offset=0" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| limit | query | optional | 1–200, default 50. Anything larger is clamped rather than refused. |
| offset | query | optional | Default 0. |
Responses
| Status | Meaning |
|---|---|
| 200 | The page, and the total. |
| 403 | A credential scoped to another project. |
| 404 | No such project in your tenant. |
| 502 | The ledger is unreachable. |
Response body
{
"wallets": [
{
"external_user_id": "ada",
"display_label": "Ada Lovelace",
"status": "active",
"balance_micros": 4992800,
"held_micros": 0,
"available_micros": 4992800
}
],
"total": 1
}Read one balance
GET/v1/projects/{project}/wallets/{external_user_id}deploy-service
What you render in your own interface when somebody asks what they have left.
Render available, not balance
held_micros is reserved against a run that has not settled yet, so it is neither spent nor spendable. available_micros is balance − held and is the only one of the three that answers "can they start another run". A balance shown mid-conversation looks wrong otherwise.
curl "$API/v1/projects/agents/wallets/ada" \ -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 200 | The wallet. |
| 403 | A credential scoped to another project. |
| 404 | No such wallet, or no such project in your tenant. An id no wallet could have — *, or one with a colon — is simply not found. |
| 502 | The ledger is unreachable. |
Response body
{
"external_user_id": "ada",
"project_slug": "agents",
"status": "active",
"currency": "USD",
"balance_micros": 4992800,
"held_micros": 12000,
"available_micros": 4980800
}Add credit
POST/v1/projects/{project}/wallets/{external_user_id}/creditsdeploy-service
Promotional, prepaid, whatever you are doing. One direction only.
This is not idempotent, and nothing will stop a double credit
Calling it twice issues the credit twice. ref is your own reference — an order id, a promotion name — and it is the only thing that will tell you afterwards which credit was which, so send one even though it is optional.
# $5.00 of promotional credit.
curl -X POST "$API/v1/projects/agents/wallets/ada/credits" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"micros": 5000000, "ref": "promo:launch"}'Body
| Field | Type | Description | |
|---|---|---|---|
| micros | integer | required | Micro-USD to add, greater than zero; 5000000 is five dollars. A fractional number is truncated to a whole one, not refused. |
| ref | string | optional | Your own reference, carried onto the statement row. |
Responses
| Status | Meaning |
|---|---|
| 201 | Credited. {transaction_id, micros}. |
| 400 | micros was not a number, or the wallet is closed. |
| 403 | This credential cannot write, or it is scoped to another project. |
| 404 | No such wallet. Create it first. |
| 502 | A credit of zero or less, which the ledger refuses and this route does not translate — use an adjustment to take credit away. Or the ledger is unreachable: retry, but read the statement first, because a timeout is not proof it did not land. |
Response body
201 {"transaction_id": "txn_3f9c…", "micros": 5000000}Correct a balance
POST/v1/projects/{project}/wallets/{external_user_id}/adjustmentsdeploy-service
A correction in either direction, which a credit cannot be. Negative claws back a credit applied in error; positive refunds a disputed charge.
The original transaction stays
The books are corrected by a second entry rather than by editing the first, so there is always a row saying where a customer's money went. That is also what makes a dispute answerable six months later.
# Claw back $2.50 credited in error.
curl -X POST "$API/v1/projects/agents/wallets/ada/adjustments" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"micros": -2500000, "ref": "applied in error"}'Body
| Field | Type | Description | |
|---|---|---|---|
| micros | integer | required | Micro-USD, signed. Negative takes credit away. Zero is refused. Nothing stops an adjustment taking a balance below zero, or below what is held. |
| ref | string | optional | Why. Carried onto the statement row — write something a colleague will understand. |
Responses
| Status | Meaning |
|---|---|
| 201 | Adjusted. {transaction_id, micros}. |
| 400 | Not a number, zero, or the wallet is closed. |
| 403 | This credential cannot write, or it is scoped to another project. |
| 404 | No such wallet. |
| 502 | The ledger is unreachable. |
Freeze, unfreeze or close
POST/v1/projects/{project}/wallets/{external_user_id}/statusdeploy-service
Stop a wallet spending without touching its money, or end it.
Freezing keeps the balance; closing needs a zero one
A frozen wallet refuses spending and keeps its money and its history, and the agent reports it as a suspended account rather than an empty one — which matters, because telling somebody you suspended to top up sends them to do the one thing that cannot help. Closing is refused while credit remains, so refund or write off what is left first.
Setting the status it already has is free
Not an error either. So a reconciliation job that asserts the intended status on every wallet costs nothing for the ones already correct.
curl -X POST "$API/v1/projects/agents/wallets/ada/status" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "frozen"}'Body
| Field | Type | Description | |
|---|---|---|---|
| status | string | required | One of active, frozen, closed. active is how you unfreeze. |
Responses
| Status | Meaning |
|---|---|
| 200 | The wallet, with its new status. |
| 400 | Unknown status, or closing a wallet that still holds credit. The body names the known values. |
| 403 | This credential cannot write, or it is scoped to another project. |
| 404 | No such wallet. |
| 502 | The ledger is unreachable. |
Read a statement
GET/v1/projects/{project}/wallets/{external_user_id}/statementdeploy-service
One signed row per movement, newest first — credits, adjustments and every run that settled against this wallet.
A run's row carries its invocation id in ref
Which is what makes a surprising charge explainable rather than merely visible: the same id reaches the run's own record and its event history.
Three kinds, and the sign is the direction
grant is a credit, adjustment is a correction either way, and settle is a run's charge. amount_micros is signed from the wallet's side: positive put money in, negative took it out.
curl "$API/v1/projects/agents/wallets/ada/statement?limit=50" \ -H "Authorization: Bearer $TOKEN"
Parameters
| Field | Type | Description | |
|---|---|---|---|
| limit | query | optional | 1–200, default 50. |
| offset | query | optional | Default 0. |
Responses
| Status | Meaning |
|---|---|
| 200 | The page, and the total. |
| 403 | A credential scoped to another project. |
| 404 | No such wallet. |
| 502 | The ledger is unreachable. |
Response body
{
"entries": [
{
"transaction_id": "txn_8a21…",
"kind": "settle",
"ref": "f27bfbb4a1c34e…",
"amount_micros": -7200,
"created_at": "2026-10-07T12:05:00+00:00"
},
{
"transaction_id": "txn_3f9c…",
"kind": "grant",
"ref": "promo:launch",
"amount_micros": 5000000,
"created_at": "2026-10-07T12:00:00+00:00"
}
],
"total": 2
}Your own balance
GET/v1/balancedeploy-service
This tenant's prepaid balance and what is currently held against it. Not a wallet — this is the account a run with no end user spends.
Denominated in TOKENS, not money
Your tenant account is quota; a wallet is money. This is the one place on this page where a figure is not micro-USD, and it is why the wallet routes name their field micros.
curl "$API/v1/balance" -H "Authorization: Bearer $TOKEN"
Responses
| Status | Meaning |
|---|---|
| 200 | The account. Zeroes, not a 404, before the first run has created it. |
| 501 | This deployment has no ledger configured. |
| 502 | The ledger is unreachable. |
Response body
{
"account_id": "acme",
"tenant_id": "acme",
"balance_units": 5000000,
"held_units": 12000,
"available_units": 4988000,
"last_entry_at": "2026-10-14T13:02:15+00:00"
}