Wallets and the ledger
Read a player's balances and post value to their wallet directly, with every change recorded as one balanced ledger entry.
A player holds value in wallets. Each wallet is identified by the player, a walletScope and,
for game wallets, a gameSlug:
walletScope | gameSlug | Holds |
|---|---|---|
platform | null | Currencies usable across your whole tenant |
game | the game's slug | Currencies tied to one game |
A wallet holds a list of balances, one per currency key. It is created the first time value is posted to it, so a player with no activity has no wallets.
Every change to a balance is a ledger posting: a transfer with its own transferId, recorded as
balanced ledger entries and applied in the same database transaction as the new balance. A balance
therefore always agrees with the sum of its ledger entries. Each posting also raises the wallet's
revision and publishes reward.wallet.balance-changed (see the event catalog).
Read a player's wallets
GET /v1/wallets/:playerId with scope rewards:read.
curl "$REWARD_API_URL/v1/wallets/player-7f3a" \
-H "Authorization: Bearer $TANITAR_API_KEY"[
{
"playerId": "player-7f3a",
"walletScope": "platform",
"gameSlug": null,
"balances": [
{ "currencyKey": "red", "amount": 1250 },
{ "currencyKey": "blue", "amount": 40 }
],
"revision": 12,
"updatedAt": "2026-09-30T10:14:02.118Z"
}
]- The response is an array with every wallet the player holds in your tenant. It is not paginated.
- A player with no wallets returns
[]. Reading never creates a wallet or a ledger entry. - The tenant always comes from your API key. A player of another tenant reads as
[], exactly like a player who does not exist. - Amounts are integers in the currency's smallest unit.
Players read their own wallets through the Player surface, GET /player/v1/wallets, which returns
the same shape.
Post value directly
Most value reaches players through grants: a mission, chest,
battle-pass tier or award settles once and records why. When your own system is the source of the
value (a purchase in your store, a migration from an earlier programme, a correction), you can post
to a wallet directly with POST /v1/wallet-value/post and scope rewards:mutate.
| Field | Rule |
|---|---|
playerId | The player whose wallet changes |
walletScope | platform or game |
gameSlug | Required when walletScope is game |
currencyKey | A currency you have published |
amount | A non-zero integer: positive credits, negative debits |
reason | Free text, stored on the ledger entry |
source | One of api-command, broker-event, currency-exchange, migration, operator-recovery |
operationKey | Your identifier for this operation, unique per intended change |
curl -X POST "$REWARD_API_URL/v1/wallet-value/post" \
-H "Authorization: Bearer $TANITAR_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{"playerId":"player-7f3a","walletScope":"platform","currencyKey":"red","amount":500,"reason":"Launch week bonus","source":"api-command","operationKey":"launch-bonus:player-7f3a"}'The response carries the transferId and the resulting wallet.
Exactly once, by operationKey
The operationKey is the posting's identity on the ledger, separate from the request's
Idempotency-Key:
- Sending the same
operationKeywith the same command again returns the original posting. The balance moves once. - Sending the same
operationKeywith a different command (another amount, player or currency) is refused with409 VALUE_OPERATION_CONFLICT.
Derive operationKey from the business event in your system (an order id, a migration row id) so a
retry after a timeout can never pay twice.
Refusals
| Status | errorCode | Cause |
|---|---|---|
400 | VALUE_INPUT_INVALID | The amount is zero, the posting would take a balance below zero or past the safe-integer range, or a game wallet was named without gameSlug |
404 | CURRENCY_UNAVAILABLE | The currency does not exist or is not published |
409 | VALUE_OPERATION_CONFLICT | The operationKey was already used for a different command |
409 | WALLET_REVISION_CONFLICT | The wallet kept changing concurrently and the posting could not commit; retry with the same operationKey |
A refused posting writes nothing: no balance changes and no ledger entry is recorded.
A direct posting is not a grant. It creates no grant record, so it does not appear in
GET /v1/reward-grants/:playerId, and it publishes reward.wallet.balance-changed but not
reward.grant-applied. If you want a delivered reward to be traceable as an award, use an award
definition with a value and grant it, as described in Award kinds and awards.