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

CallerLedgerAudit log
Your server, with a secret keyEverything for its own gameAppend, read, verify
Your page, with a publishable key and a player tokenRead that player’s own account and rowsNothing

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

Your server
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

Your server
// 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 ref is 1 to 200 characters, a kind is lowercase like daily-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}/freeze and { actor, reason }; /unfreeze undoes it. Each writes an audit entry.

Reading

MethodPathPurpose
GET/v1/ledger/accounts/{id}Balance and whether it is frozen
GET/v1/ledger/accounts/{id}/rowsStatement, newest first. limit up to 100; before = the last id you saw
GET/v1/ledger/supplyWhat exists (held by players and treasuries, with the frozen part), and what was created and destroyed, by kind. balanced: true means created less destroyed equals what exists
GET/v1/ledger/reconcileEvery account whose balance differs from its rows. whole: true means none
GET/v1/ledger/totals?days=30Count, in and out by kind and by day (Lagos), up to 90 days
GET/v1/ledger/meFrom a page: the signed-in player’s balance
GET/v1/ledger/me/rowsFrom a page: the signed-in player’s statement

The audit log

Your server
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

CodeStatusWhen
module_off503Arena54 has the module switched off
module_not_enabled403The module is not ticked for this game
secret_key_required403A publishable key asked for more than /ledger/me
secret_key_in_browser403A secret key arrived from a page
not_enough409The account does not hold enough. Nothing moved
frozen409The account is frozen. Nothing moved
id_conflict409That kind and ref were already used for a different account
no_account404This game has no account with that id
bad_amount400A delta that is not a whole number other than zero, or a missing kind or ref
unbalanced400A transfer whose deltas do not sum to zero
bad_request400A body that is not the shape asked for
limit429The 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.