Ledger and audit log
What this is
Two optional modules for a registered game. The ledger holds the game’s own currency: accounts, movements that happen once, and a balance that cannot go below zero. The audit log holds the game’s staff actions in a chain that shows if an entry was changed. Both belong to one game. Nothing here moves between games, and nothing is real money.
Each module has a switch on Arena54’s side. While a switch is off, its calls answer 503 module_off. Tick Ledger or Audit log for your game in the dashboard either way, and create a secret test key for it.
Who may call
| Caller | Ledger | Audit log |
|---|---|---|
| Your server, with a secret key | Everything for its own game | Append, read, verify |
| Your page, with a publishable key and a player token | Read that player’s own account and rows | Nothing |
A page never writes, because anything a page could write, a player could write. So a game with no server cannot grant or spend currency yet. A secret key sent from a browser is refused with 403 secret_key_in_browser: keep it in an environment variable on your server, for example ARENA54_SECRET_KEY, and never commit it.
A test key writes to a test ledger and a test chain of its own. They never touch the live ones.
Accounts
const API = "https://developers.arena54.com/api/v1";
const headers = { Authorization: "Bearer " + process.env.ARENA54_SECRET_KEY };
const call = async (method, path, body) => (await fetch(API + path, { method, headers, body: body && JSON.stringify(body) })).json();
// One account per owner. Opening it again returns the same one.
const player = (await call("POST", "/ledger/accounts", { kind: "player", owner: playerId })).data;
const shop = (await call("POST", "/ledger/accounts", { kind: "treasury", owner: "shop" })).data;An account is a player, a treasury (the game’s own holdings) or a sink (where currency goes to be destroyed; it alone may go below zero). For a player, use the id from /play/me as the owner, and the page’s /ledger/me will find it.
Moving currency
// One account: makes or removes currency. Whole numbers only. `ref` is yours and makes it happen once.
await call("POST", "/ledger/movements", { account: player.id, delta: 100, kind: "daily-grant", ref: "grant-2026-10-12-" + playerId });
// → { id, balance_after: 100, replay: false }. Send it again and you get the same answer with replay: true.
// Several accounts: moves currency, makes none. All legs or none.
await call("POST", "/ledger/transfers", { ref: "order-8841", legs: [
{ account: player.id, delta: -40, kind: "buy" },
{ account: shop.id, delta: 40, kind: "buy" },
]});- Decide the amount on your server. Never take a delta from the page.
- A
refis 1 to 200 characters, akindis lowercase likedaily-grant. The same kind and ref again is a repeat, answered from the stored row whatever the repeat asked for. - A correction is a new movement with its own kind. There is no edit and no delete.
- Freeze an account with
POST /ledger/accounts/{id}/freezeand{ actor, reason };/unfreezeundoes it. Each writes an audit entry.
Reading
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/ledger/accounts/{id} | Balance and whether it is frozen |
| GET | /v1/ledger/accounts/{id}/rows | Statement, newest first. limit up to 100; before = the last id you saw |
| GET | /v1/ledger/supply | Held by players, by treasuries, and in sinks |
| GET | /v1/ledger/reconcile | Every account whose balance differs from its rows. whole: true means none |
| GET | /v1/ledger/totals?days=30 | Count, in and out by kind and by day (Lagos), up to 90 days |
| GET | /v1/ledger/me | From a page: the signed-in player’s balance |
| GET | /v1/ledger/me/rows | From a page: the signed-in player’s statement |
The audit log
await call("POST", "/audit", { actor: "mod-ada", action: "player.ban", target: playerId, reason: "cheating", details: { days: 7 } });
const recent = await call("GET", "/audit?action=player.ban&limit=20"); // also actor=, target=, before=
const check = await call("GET", "/audit/verify"); // { whole: true, first_bad_id: null }Each entry stores a hash of the one before it, so a changed or missing entry shows at /audit/verify. Arena54 adds the time. An action is lowercase like player.ban; details are 4,000 characters at most. There is no edit and no delete.
Refusals
| Code | Status | When |
|---|---|---|
| module_off | 503 | Arena54 has the module switched off |
| module_not_enabled | 403 | The module is not ticked for this game |
| secret_key_required | 403 | A publishable key asked for more than /ledger/me |
| secret_key_in_browser | 403 | A secret key arrived from a page |
| not_enough | 409 | The account does not hold enough. Nothing moved |
| frozen | 409 | The account is frozen. Nothing moved |
| id_conflict | 409 | That kind and ref were already used for a different account |
| no_account | 404 | This game has no account with that id |
| bad_amount | 400 | A delta that is not a whole number other than zero, or a missing kind or ref |
| unbalanced | 400 | A transfer whose deltas do not sum to zero |
| bad_request | 400 | A body that is not the shape asked for |
| limit | 429 | The day’s movements or entries for this game, on your plan, are used up. Nothing was written |
Limits
Movements a day and audit entries a day for a game come from your plan, with the usual calls a minute per key. See pricing.