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

GET/api/v1/reconciliationAPI key
Scope ledger:read. Optional ?from=, an ISO-8601 timestamp; defaults to the accounting epoch, before which trades legitimately predate the ledger.
200 application/json (abridged)
{
  "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: false means 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 — and ending, 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.
  • venueAgreement counts exits where the venue's own report matched ours. unknown is not a disagreement — it is an exit the venue has not answered for.

Findings

json
{
  "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

GET/api/v1/reconciliation/feedAPI key
Scope ledger: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.

200 application/json
{
  "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 typeThe fact it records
order.state_changedAn order moved between states.
order.filledA fill landed against an order.
fee.assessedA fee was charged.
collateral.movedCollateral was committed or returned.
ledger.postedA double-entry movement was written.
position.exitedA position was reduced or closed.
position.settledA position resolved.
webhook.emittedAn 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.

typescript
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: false means 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 type is 400 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_cursor rather 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 — or null for operator-level facts. Never Predicta's internal uuid.

Which surface answers which question

QuestionWhere
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 scratchGET /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.