In-process is the primary interface
The ledger is a server-authoritative primitive — every call below is atomic, idempotent and overdraft-safe by database constraint, and the reference has the signatures. Your own code — a game model, a reward handler, a trusted route — calls it directly against the DB binding.
import { openLedger } from "@pithy-sh/ledger/src/ledger";
const ledger = openLedger(env.DB);
await ledger.credit("alice", "chips", 1000, "signup-bonus:alice");
await ledger.debit("alice", "chips", 50, "buyin:table-7:hand-3");
await ledger.transfer("alice", "bob", "chips", 30, "tip:xyz");
const { balance, held, available } = await ledger.balance("alice", "chips");A credit opens the account if it does not exist. There is no separate create step — an account is (userId, currency), and the first credit upserts it.
Pass a unique ref for every operation so retries are safe. See idempotency refs — this is the one thing to get right.
Wagering
Three calls and three numbers: holds and settlement is the model, and an in-app economy is it applied to a game.
await ledger.hold("alice", "chips", 100, "bet:hand-9:alice");
await ledger.capture("bet:hand-9:alice"); // she lost
// or
await ledger.release("bet:hand-9:alice"); // pushedThe HTTP surface
Balance reads are for players. Moving funds over HTTP is server-authoritative.
| Route | Verified by |
|---|---|
GET /ledger/:currency | user |
GET /ledger/:currency/transactions | user |
POST /ledger/:currency/credit | user + the admin scope |
POST /ledger/:currency/debit | user + the admin scope |
Add the auth capability. Without it every route is denied.
The management surface is read-only, permanently
| Route | Scope |
|---|---|
GET /ledger/admin/accounts | ledger:accounts:read |
GET /ledger/admin/accounts/:userId | ledger:accounts:read |
GET /ledger/admin/accounts/:userId/:currency/transactions | ledger:transactions:read |
There is no adjustment route, and that is deliberate — see regulatory concerns.
These routes are always mounted. Without the control plane composed, each denies with controlplane/not_connected — a management surface that appears only when something else is installed is a surface nobody can discover.
Paths follow your basePath: mount the ledger at /wallet and the manifest advertises /wallet/admin/accounts.
What to do when a debit fails
ledger/insufficient_funds means the constraint refused it — the balance genuinely could not cover it, at the moment of writing.
Do not retry it hoping. A retry with the same ref is a no-op if the first one landed and another refusal if it did not. The recovery is upstream: tell the player, or credit them first.
Composing with a game
// at the wager
await ledger.hold(player, "chips", stake, `bet:${sessionId}:${player}`);
// at the result
for (const loser of losers) await ledger.capture(`bet:${sessionId}:${loser}`);
for (const winner of winners) {
await ledger.release(`bet:${sessionId}:${winner}`);
await ledger.credit(winner, "chips", payout, `payout:${sessionId}:${winner}`);
}Every ref names the session and the player, so the whole settlement is replayable and a retried result settles once.