Money

Ledger

Predicta’s own double-entry books. Every movement is one record whose debits equal its credits, and balances are derived by summing lines rather than stored — a cached balance is a second source of truth for the same money.

What this is, and is not

Ledger tables livePosting engine liveRead endpoints sandbox onlyScope ledger:read

Sandbox only — 403 sandbox_only in live mode

This endpoint models a balance held by Predicta, which a live operator does not have. It exists so an integration can be learned against simulated money.

In production you own your customers' cash ledger. Predicta returns the exact amounts to apply against it — operatorMoney, operatorExitMoney, settlementCredit — and never holds a player's funds. Do not build the live integration on this endpoint.

What follows describes Predicta's internal accounting. It is real, it is under test, and it is not your customers' wallet. Read it to understand how the sandbox behaves and how a reconciliation is proved — not to build a live balance against.

The one thing here that is part of the live integration is the reconciliation surface, which is available to every operator: a verdict, and a resumable stream of the money facts recorded for you.

The chart of accounts

Six account kinds. The split between them is the accounting model, not a naming convention.

KindTypePer userHolds
user_availableliabilityyesCash the player may spend.
user_reservedliabilityyesCash earmarked against a working order. Still the player’s money.
position_costliabilitynoWhat open contracts cost: never what they are worth.
treasury_cashassetnoFunds held in the operator treasury wallet.
fee_revenueincomenoPredicta’s commission.
house_equityequitynoSandbox funding source, and the counterparty trading P&L is booked against.

position_cost is cost, not a mark

A position's value moves every time the venue reprints. The cash that bought it does not, and only cash belongs in a ledger. What a holding is worth today is a portfolio question answered from prices — never by reading a balance.

Movement types

Every entry carries a movementType, which is what makes a statement groupable without maintaining a second set of counters.

MovementEffect on the player’s cash
SANDBOX_CREDITIncrease. Test funds, from house equity. Sandbox tenants only.
DEPOSITIncrease. A funded, credited deposit.
WITHDRAWALDecrease.
ORDER_RESERVENeutral. Moves cash from available to reserved.
RESERVE_RELEASENeutral. Moves it back.
BUYDecrease. Cash becomes position cost.
FEEDecrease. Cash becomes fee revenue.
SELLIncrease.
SETTLEMENTIncrease by the payout.
VOID_REFUNDIncrease. Everything committed, fee included.
ADJUSTMENTEither. A correction, always pointing at the entry it corrects.

ORDER_RESERVE and RESERVE_RELEASE net to zero on the player's cash total. That is exactly why the figure a reconciliation proves is available + reserved, and not available alone.

The order path posts into these books: a buy writes BUY and FEE, an exit writes SELL and its own fee, and settlement writes SETTLEMENT or VOID_REFUND. The same postings happen for a live tenant — what differs is that a live tenant has no per-user cash accounts to read back, because those customers' cash is on your books.

The three rules that make it trustworthy

Entries balance before they are written

Debits and credits are summed and compared before the insert. An unbalanced entry cannot exist, not even briefly inside a transaction that would later have corrected it.

Nothing is ever edited

A database trigger rejects UPDATE on entries and lines outright. A correction is a new entry carrying correctsEntryId, so the record of what was believed at the time survives alongside the record of what was true.

One fact posts once

(operatorId, idempotencyKey) is unique, and the key is derived from the fact — the settlement, the fill, the deposit — never from the attempt to record it. A replayed settlement, a retried fill and a redelivered bridge webhook all collide with that index rather than paying twice.

Why that matters to you

It is what makes retrying the correct response to a timeout on any money-moving call. The second attempt returns the original and writes nothing.

Reading a sandbox balance

GET/api/v1/users/{id}/balanceAPI key
The player's derived balance. {id} is your id for them — the same value you send as X-Predicta-User. Optional ?currency=, default USD. Scope ledger:read. 403 sandbox_only in live mode.
200 application/json
{
  "userId": "your-own-player-id",
  "status": "active",
  "currency": "USD",
  "mode": "sandbox",
  "available": 240.00,
  "reserved": 10.00,
  "cash": 250.00,
  "positionCost": 62.50,
  "asOf": "2026-08-21T17:04:12.115Z"
}
  • available: spendable now.
  • reserved: committed to a working order, not yet spent.
  • cash: available + reserved. The only figure here that is cash.
  • positionCost: stated separately and deliberately outside the total. Folding it in would show spending power that does not exist — a position is worth whatever the venue says today and it cannot be spent.
  • mode comes from your operator record, not from anything in the request. It is the reliable way to tell which tenant a figure belongs to.
  • Reading a balance does not create a player. An id that has never traded or deposited returns 404 unknown_user: a typo should surface as an error, not as a confident zero.

Reading the movements

GET/api/v1/users/{id}/ledgerAPI key
The entries behind that balance, newest first. ?limit= defaults to 50 and caps at 200; ?cursor= continues the page. Scope ledger:read. 403 sandbox_only in live mode.
200 application/json
{
  "userId": "your-own-player-id",
  "movements": [
    {
      "id": "…",
      "type": "SETTLEMENT",
      "amount": 68.61,
      "currency": "USD",
      "referenceType": "position_settlement",
      "referenceId": "…",
      "memo": null,
      "correctsEntryId": null,
      "createdAt": "2026-08-21T17:04:12.115Z"
    }
  ],
  "count": 1,
  "nextCursor": "…"
}
  • amount is the entry's magnitude and is always positive. Its direction comes from type. Switch on the movement rather than inferring intent from a sign: a FEE and a WITHDRAWAL both reduce cash and mean entirely different things.
  • referenceType and referenceId point at the fact that caused the movement — an order, a position settlement, a deposit — which is what lets a statement be joined back to your own records.
  • correctsEntryId is non-null on a correction, naming the entry it supersedes. Neither entry is ever removed.
  • Page until nextCursor is null. The cursor is an entry id, not a timestamp, because createdAt is stored to the microsecond and serialises to the millisecond — a timestamp cursor silently skips rows landing inside the same millisecond.
  • The debit and credit lines are not exposed. Publishing them would make Predicta's chart of accounts part of the partner contract, unchangeable without a breaking change.

The identity a reconciliation proves

A statement is not a summary of what the code believes. Every term is a sum of lines against the two user cash accounts, grouped by the movement that produced them, so the identity asks a real question: do the movements posted add up to the balance held?

text
opening
  + deposits          (SANDBOX_CREDIT, DEPOSIT)
  + sales             (SELL)
  + settlements       (SETTLEMENT, VOID_REFUND)
  + adjustments       (ADJUSTMENT)
  − purchases         (BUY)
  − fees              (FEE)
  − withdrawals       (WITHDRAWAL)
  = ending

GET /api/v1/reconciliation returns both expectedEnding (what the identity predicts) and ending (what the accounts say), plus their difference and a reconciled flag. Two figures rather than one, because a statement that only ever reports the number it computed cannot fail. That endpoint is not sandbox-gated — see Reconciliation.