Lifecycle
Reconciliation
A verdict and a stream. The verdict says whether Predicta’s records, the cash ledger and the venue agree; the stream is every money fact Predicta recorded for you, totally ordered and resumable.
The verdict
/api/v1/reconciliationAPI keyledger:read. Optional ?from=, an ISO-8601 timestamp; defaults to the accounting epoch, before which trades legitimately predate the ledger.{
"ranAt": "2026-08-22T09:00:04.118Z",
"from": "2026-01-01T00:00:00.000Z",
"ok": true,
"trialBalance": { "debits": 41230.5, "credits": 41230.5, "balanced": true },
"cashStatement": { "opening": 0, "ending": 12840.25,
"expectedEnding": 12840.25, "difference": 0, "reconciled": true },
"scanned": { "entries": 1841, "lines": 4102, "accounts": 37 },
"venueAgreement": { "exits": 96, "agrees": 96, "disagrees": 0, "unknown": 0 },
"findings": []
}ok: falsemeans at least one finding needs a human. It is not a transient condition to retry past.- The cash statement carries both
expectedEnding— what the identity predicts — andending, what the accounts actually hold. Two figures rather than one, because a statement that only ever reports the number it computed cannot fail. - The trial balance is necessarily zero if every entry balanced when posted and nothing can edit one afterwards. A non-zero difference means something wrote outside the posting engine, which is exactly the condition worth alarming on.
venueAgreementcounts exits where the venue's own report matched ours.unknownis not a disagreement — it is an exit the venue has not answered for.
Findings
{
"check": "position_cost_mismatch",
"severity": "critical",
"detail": "position cost 24.70 vs ledger 24.69",
"subjectType": "user",
"subjectId": "player-8421"
}subjectType tells you which id space subjectId is in. user means it is your own id for a customer; record means a Predicta id — an entry, order, position or account — that you can quote back to us in a support thread. Predicta's internal user uuid is never published: an identifier that means nothing in your system is one you would only ever store and then have to reconcile against the one you already have.
The fact stream
/api/v1/reconciliation/feedAPI keyledger:read. ?cursor= opaque, ?type= repeatable, ?externalUserId=, ?limit= 1–500 (default 100).Resumption is the whole design. Store nextCursor, send it back, and you receive every fact recorded since and nothing you have already seen.
{
"items": [
{
"id": "led_...",
"type": "ledger.posted",
"cursor": "…",
"occurredAt": "2026-08-21T04:43:40.301Z",
"externalUserId": "player-8421",
"positionId": "pos_...",
"orderId": "3dd27f36-...",
"money": { "currency": "USD", "amount": "24.70" },
"detail": { "movementType": "BUY" }
}
],
"nextCursor": "…",
"hasMore": true
}| Item type | The fact it records |
|---|---|
order.state_changed | An order moved between states. |
order.filled | A fill landed against an order. |
fee.assessed | A fee was charged. |
collateral.moved | Collateral was committed or returned. |
ledger.posted | A double-entry movement was written. |
position.exited | A position was reduced or closed. |
position.settled | A position resolved. |
webhook.emitted | An event was queued for delivery. |
Reading does not consume
The same cursor returns the same items forever, so a consumer that crashes mid-batch simply re-reads. That is what makes the correct pattern possible: advance your stored cursor only once the batch is applied, never on receipt.
Apply idempotently on id, which is the underlying fact's own id rather than an id for this delivery of it.
let cursor = await store.readCursor();
for (;;) {
const page = await predicta.readFeed({ cursor, limit: 500 });
for (const fact of page.items) await apply(fact); // idempotent on fact.id
if (!page.nextCursor) break;
cursor = page.nextCursor;
await store.writeCursor(cursor); // only after applying
if (!page.hasMore) break;
}hasMore: falsemeans caught up — poll again later with the same cursor. Caught up means “everything provably final”, not “everything that exists this instant”: the head of the stream waits for in-flight writes to land, so a row may arrive a moment later than it committed and will never fail to arrive.- An unrecognised
typeis400 invalid_type, not silently ignored. Dropping it would return a feed that looks complete and is not — you filter for a type you misspelled, see nothing, and conclude the facts do not exist. - A malformed cursor is
400 invalid_cursorrather than a restart from the beginning. Replaying an entire history because of a typo is an expensive way to be lenient. - Items carry
externalUserId— your own id — ornullfor operator-level facts. Never Predicta's internal uuid.
Which surface answers which question
| Question | Where |
|---|---|
| What must I credit this customer? | GET /api/v1/settlements |
| What is still open on our book? | GET /api/v1/positions?status=open |
| What happened to this one order? | GET /api/v1/orders?externalUserId=&clientOrderId= |
| Rebuild our own ledger from scratch | GET /api/v1/reconciliation/feed |
| Do the books agree right now? | GET /api/v1/reconciliation |
None of these is a balance to read. Predicta holds no customer funds; the reconciliation surface exists so you can prove your own ledger against the facts we recorded, which is a different and stronger thing than trusting a number we report.

