You need: ledger composed, and auth — reads scope to the caller. Add payments if you sell the currency.
Correctness is the product
The ledger is what makes that claim checkable, wager, escrow, settle is the pattern for staking a balance, and designing your catalog is how currency gets sold in the first place.
A balance store that can double-spend or go negative is not a balance store. Three invariants hold no matter how operations interleave or how many times they are delivered — and they are enforced by the database rather than by hopeful application code.
Atomic. Each operation is one transaction: the ledger entry and the balance change commit together or not at all. There is never a recorded movement with no matching balance change.
Idempotent. Every operation carries a reference you supply, written as a unique row. A replay inserts a duplicate, which aborts the transaction, and the operation returns the balance unchanged. A payout delivered twice pays once.
Overdraft-safe. A debit or a hold is applied by an update guarded by a check constraint on the account. A movement that would break solvency — even against a balance another concurrent operation just lowered — aborts and surfaces a specific error. No race slips past a constraint.
Amounts are integers in the minor unit
Never floats, so arithmetic is exact. A currency’s decimals say only how to display them.
ledger({
currencies: [
{ code: "chips", name: "Casino Chips" },
{ code: "gold", name: "Gold", decimals: 2 },
],
}),150 in gold displays as 1.50. Decide the unit before the first movement, because it is baked into every row afterwards — if your product has a concept of half a coin, the half-coin is your unit and one coin is two of them.
A currency code is effectively permanent. It is stored on every movement.
Choose your reference so a retry produces the same one
This is the entire idempotency contract, and it is the thing people get wrong.
await ledger.credit({ userId, currency: "coins", amount: 100, ref: `signup:${userId}` });
await ledger.debit({ userId, currency: "coins", amount: 30, ref: `purchase:${orderId}` });A reference derived from a timestamp or a random id defeats it, because the retry generates a different one and the movement happens twice.
Derive it from the thing that caused the movement — an order id, a match id, a payout id, a purchase’s store transaction id. Then a network timeout you cannot classify becomes safe to retry, which is the only safe response to that ambiguity.
Holds are what make wagering safe
A bet is not a debit. A bet is a hold.
The stake is reserved the moment it is placed — unavailable, but not yet spent — and then either released (the bet was canceled) or captured (the hand resolved).
const hold = await ledger.hold({ userId, currency: "chips", amount: 50, ref: `bet:${handId}` });
// … the hand plays out …
await ledger.capture(hold.id, { ref: `settle:${handId}` });Without holds, a player can place a bet, spend the same chips elsewhere while the hand is running, and be unable to pay when they lose. With them, the chips are unavailable from the moment they are staked, and the settlement is a state change rather than a race.
That is why this pairs with multiplayer: an authoritative session resolves a hand, and the ledger settles it.
Moving somebody else’s balance needs a scope
Reads scope to the caller. A credit or debit against another player requires the admin scope, minted for your own server-side code and never for a device.
If your game awards coins for winning, the award is made by your Worker after it decided who won — not by the client reporting that it did. A client that can credit itself is not a ledger.
Selling the currency
A product’s catalog entry declares the grant:
coins_100: {
type: "consumable",
name: "100 coins",
grants: { currency: "coins", amount: 100 },
apple: { productId: "com.acme.coins100" },
},Whether it credits depends on the purchase status, and the distinction is did the money ever arrive. A purchase that ended before any payment cleared credits nothing — and reading it as merely expired would credit a pack for money that never came, with no clawback ever to follow.
Refunds and payments management has the table.
What this is not for
Scores and rankings are a leaderboard. Ranked, windowed, joinable.
XP and levels are a leaderboard board, or a small table of your own. They only ever go up, you never spend a level, and none of the hold, overdraft or transfer machinery applies — using a ledger for XP is forcing a spend-and-settle model onto a counter.
The one genuine overlap is spendable reward points. Earn them, redeem them for something — that is a ledger currency, because you spend it. If a player can never spend it, it is a score rather than a balance.
Money and regulation are yours
The ledger takes no position on whether your units map to money.
If they do, know-your-customer rules, licensing, responsible-gaming limits and payment rails are your concern. Pithy provides the ledger; you provide the compliance — and the constraint that keeps this correct is not the constraint that keeps that legal.
Check it worked
- A debit larger than the balance fails with the insufficient-funds error rather than going negative
- Replaying a credit with the same reference changes nothing
- A hold makes the amount unavailable to a second debit, and releasing gives it back
- A client cannot credit itself
- Two concurrent debits that would together overdraw: one succeeds, one is refused