pithy add ledgerNo required prerequisites. Add auth if it is not already there: reads scope to the caller, so without an identity composed every route denies.
What lands in your repo
apps/<worker>/pithy.config.ts gains the registration, with an example currency you are meant to replace:
ledger({
currencies: [{ code: "chips", name: "Chips" }],
adminScope: "ledger:admin",
}),apps/<worker>/wrangler.jsonc gains the DB binding, in every environment stanza. That is the only binding this capability needs — no bucket, no namespace, no Workflow.
One migration runs, creating the balance and movement tables and the constraint that makes an overdraft impossible.
The two config options
| Option | Default | What it decides |
|---|---|---|
currencies | one example, chips | Every currency this app’s ledger holds |
adminScope | ledger:admin | The scope a session must carry to credit or debit another player’s balance over HTTP |
Replace the example currency
chips is the schema’s own example — the smallest ledger that does anything. Your currencies are a list, and each is an independent balance per user:
currencies: [
{ code: "coins", name: "Coins" },
{ code: "gems", name: "Gems" },
],There is no conversion between them. Two currencies is two balances, and moving value across is a movement you author on both sides.
A currency code is effectively permanent. It is stored on every movement row, so renaming one is a data migration rather than a config edit. Pick codes you can live with.
The admin scope is the whole authorization model
Reads scope to the caller. Moving somebody else’s balance — a payout, a grant, a correction — requires the admin scope, which you mint for your own server-side code.
That default inverts the usual vendor posture on purpose: a client that can credit itself is not a ledger. 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.
Minor units, decided once
A currency’s smallest indivisible unit is the unit the ledger stores. There are no fractions, and there is no rounding, because rounding a balance is how a balance stops adding up.
If your product has a concept of half a coin, the coin is not your unit — the half-coin is, and one coin is two of them. Decide that before the first movement, because it is baked into every row afterwards.
The three calls you will actually make
A movement is a credit, a debit, or a hold that is later released or captured. Every one carries an idempotency reference of your own choosing — an order id, a match id, a payout id.
await ledger.credit({ userId, currency: "coins", amount: 100, ref: `signup:${userId}` });
await ledger.debit({ userId, currency: "coins", amount: 30, ref: `purchase:${orderId}` });
const hold = await ledger.hold({ userId, currency: "coins", amount: 50, ref: `bet:${handId}` });Choose the reference so a retry produces the same one. That is the entire contract: a repeated reference is a no-op rather than a second movement, and a timeout you cannot classify becomes safe to retry.
A reference derived from a timestamp or a random id defeats it, because the retry generates a different one.
Selling a currency pack
payments is what takes the money; this is what holds the result. A product’s catalog entry declares a balance grant, and the seam between the two does the rest:
coins_100: {
type: "consumable",
name: "100 coins",
grants: { currency: "coins", amount: 100 },
apple: { productId: "com.acme.coins100" },
},Most products never touch a balance. Composing payments is only necessary if you sell one.
Check it worked
pithy doctor reports ledger under the Worker’s health. In dev, credit a seeded user and read the balance back — pithy seed then two calls is the whole verification, and it exercises the constraint as well as the wiring if you try to debit more than you credited.