Getting started

TypeScript SDK

A thin, typed client over /api/v1. Thin on purpose: everything that decides money is computed on the server and returned as an exact instruction, so a client that helpfully derived a debit would be a second answer to a question that already has one.

Where it lives

Source src/sdkRuntime Node 20+Dependencies noneMoney type Decimal

The client is dependency-free and uses the platform fetch, so it runs in Node, in a worker and in an edge runtime unchanged. A fetch can be injected for tests or for a proxy-aware agent.

typescript
import { PredictaClient } from '@/sdk';

const predicta = new PredictaClient({
  baseUrl: process.env.PREDICTA_BASE!,   // the host issued to you
  apiKey:  process.env.PREDICTA_KEY!,    // pk_test_… sandbox, sk_live_… live
  timeoutMs: 15_000,                     // per-request deadline (default)
  maxRetries: 2,                         // GETs only (default)
});

const player = predicta.forUser('your-own-player-id');
  • Catalogue and reporting hang off the client. Anything that acts for one end user hangs off forUser, so the X-Predicta-User header cannot be forgotten.
  • predicta.environment is read off the key prefix. The boundary is a property of your tenant server-side, so a client-side flag could only ever disagree with the truth; the prefix at least cannot be wrong about which key you are holding.
  • predicta.rateLimit carries the limiter's own figures from the last response, on success as well as refusal.

Money is a Decimal, never a number

Every exact amount is a branded Decimal — a string at runtime, and not assignable from string or number at compile time. The mistake it prevents is the one that costs money: parsing "12.30" as a float leaves the exact world before your ledger ever sees the figure, and the first symptom is a reconciliation out by a cent nobody can explain.

typescript
import { toMinorUnits, addDecimal, formatDecimal } from '@/sdk';

const debit = order.operatorMoney!.actualDebit;   // Decimal — "12.30"

toMinorUnits(debit);           // 1230n — exact, bigint, the only way to do arithmetic
addDecimal(debit, release);    // "25.00"
formatDecimal(debit);          // "$12.30" — display only. Never book from a formatter.

const wrong: Decimal = 12.30;  // ✗ type error, which is the point

The SDK checks the money contract before you see it

Both identities are asserted on every response, in exact minor units: authorizationAmount = actualDebit + releaseAmount and actualDebit = tradeAmount + platformFee + venueFee. A response that breaks one throws MoneyIntegrityError rather than returning.

They hold by construction on our side, so a break means the payload was altered in transit or came from a fixture that drifted. Booking an amount that does not decompose reconciles against nothing, which is worse than an exception.

The direction is checked too. A sell that arrived carrying an authorizationAmount would have a wallet debit a player for selling their own position; that response throws instead.

The standard integration, end to end

You own the customer's cash ledger. Predicta prices, routes, fills and tells you the exact amounts. The whole integration is applying them.

typescript
// 1. Find something tradable. `executableYesCents` is the PRICE;
//    `probability` is the market's reference and nobody trades at it.
const page = await predicta.listEvents({ tradable: true, limit: 5 });
const outcome = page.items
  .flatMap((e) => e.outcomes)
  .find((o) => o.executableYesCents !== null);

// 2. Price it. Show the player `executionPrice`, and let them confirm.
const quote = await player.quoteBuy({
  contractId: outcome.id,          // the opaque ctr_… id from the catalogue
  side: 'YES',
  stake: 25,
});

// 3. Reserve the exact amount in YOUR OWN ledger, before submitting.
const reservation = await wallet.reserve(quote.operatorMoney!.authorizationAmount);

// 4. Submit. `clientOrderId` is your idempotency key — derive it from the
//    reservation, never from a clock: it has to survive the crash that loses
//    you our response.
const order = await player.submitOrder({
  quoteId: quote.quoteId,
  clientOrderId: reservation.id,
});

// 5. Apply the exact amounts. On a PARTIAL FILL the release is non-zero, and
//    an operator that debits the whole stake instead is keeping money that is
//    not theirs to keep.
await wallet.debit(order.operatorMoney!.actualDebit);
await wallet.release(order.operatorMoney!.releaseAmount);

Exiting

typescript
// A sell is sized in CONTRACTS. A cash-sized exit cannot express "close out",
// because inverting the fee rounding either oversells or strands a fraction.
const [held] = (await player.listPositions({ status: 'open' })).items;

const exitQuote = await player.quoteSell({
  contractId: held.contractId,
  side: held.side,
  contracts: held.quantity / 2,        // a partial exit
});

const exit = await player.submitOrder({
  quoteId: exitQuote.quoteId,
  clientOrderId: `exit-${reservation.id}`,
});

// An exit CREDITS. `sellProceeds` is already net of the fees reported beside
// it — subtracting them again is a double charge. `operatorMoney` is null.
await wallet.credit(exit.operatorExitMoney!.sellProceeds);

Settling

typescript
for await (const s of predicta.iterateSettlements({ settledFrom: lastRun })) {
  // Exactly one is non-zero; a loss zeroes both. They are different
  // instructions to book: winnings against a wager, versus a refund that
  // reverses one.
  if (s.outcome === 'won')  await wallet.credit(s.operatorMoney.settlementCredit, s.id);
  if (s.outcome === 'void') await wallet.refund(s.operatorMoney.voidCredit, s.id);
}

s.id is stable, so keying your credit on it makes the consumer safe to run twice. Predicta will not stop you crediting from something else — but it is the only identifier here that cannot change.

Typed errors, and the one that is not a failure

Every refusal becomes a class, so the branch that decides whether money is released is one the compiler knows about.

ClassWhenWhat to do
ReconciliationRequiredError409 reconciliation_requiredHOLD the authorization. Not a rejection.
TimeoutErrorYour deadline elapsedHold. The order may have been accepted after you stopped listening.
ConflictError409 quote_expired / quote_already_used / price_staleRe-quote and let the player confirm the new number.
SandboxOnlyError403 sandbox_onlyYou are on the learning path. Move to the instruction contract.
InsufficientScopeError403, carries requiredProvision a key with that scope. Retrying never helps.
RateLimitError429, carries retryAfterSecondsWait the server’s own figure, not a guess.
AuthenticationError401The key is absent, unrecognised or revoked.
NotFoundError404Includes “not yours”, deliberately — the two are indistinguishable.
InvalidRequestError400 / 422Fix the payload or the stake.
MoneyIntegrityErrorAn amount did not decomposeDo not book it. Escalate.
typescript
import { ConflictError, ReconciliationRequiredError } from '@/sdk';

try {
  const order = await player.submitOrder({ quoteId, clientOrderId });
  await wallet.settle(order.operatorMoney!);
} catch (err) {
  if (err instanceof ReconciliationRequiredError) {
    // The venue took it or it did not, and we do not yet know which.
    // Releasing here strands a live position with no cash behind it — the
    // most expensive mistake available on this API.
    await wallet.hold(clientOrderId);
    return;
  }
  if (err instanceof ConflictError) return requote();   // the price moved
  throw err;
}

Resolving an unknown

typescript
// Poll with the id YOU chose. Ours only ever existed in a response you may
// not have received; yours was chosen before the request was sent.
const order = await player.findOrderByClientOrderId(clientOrderId);

if (order?.settlementState === 'settled') {
  await wallet.settle(order.operatorMoney ?? order.operatorExitMoney);
}
// 'pending' — including status 'submitted' — means keep holding.

Retries, timeouts and pagination

  • Reads retry; writes do not. clientOrderId makes a retry safe, which is not the same as silent: an operator whose wallet has already released an authorization must not have the SDK place the order again behind their back. That decision belongs to the integration, with its own ledger in view.
  • A 429 is retried against the server's own reset window rather than an invented backoff — exponential backoff competing with a fact it was given is just a slower guess.
  • Every cursored feed has an iterate* async generator. listEvents/iterateEvents, listOrders/iterateOrders, listPositions/iteratePositions, listSettlements/iterateSettlements, readFeed/iterateFeed.
  • A page with no nextCursor is the end — legitimately, including the first page. A short page is not itself a promise there is nothing more, so loop on the cursor and never on items.length.
  • The iterators throw if a cursor is returned twice. A caching proxy in front of the API turns while (nextCursor) into an infinite loop that hammers the limiter and never terminates; failing loudly is the only outcome a caller can act on.

The public surface

CallEndpoint
listEvents / iterateEventsGET /api/v1/events
getEventGET /api/v1/events/{id}
listCategoriesGET /api/v1/categories
imageUrlGET /api/v1/images/{id}
forUser().quoteBuy / quoteSellPOST /api/v1/quotes
forUser().submitOrderPOST /api/v1/orders
forUser().getOrderGET /api/v1/orders/{id}
listOrders / findOrderByClientOrderIdGET /api/v1/orders
listPositions / iteratePositionsGET /api/v1/positions
forUser().getPositionGET /api/v1/positions/{id}
listSettlements / iterateSettlementsGET /api/v1/settlements
reconciliationReportGET /api/v1/reconciliation
readFeed / iterateFeedGET /api/v1/reconciliation/feed
sandbox.balance / sandbox.movementsGET /api/v1/users/{id}/…
sandbox.supportedAssets / createDepositAddress / deposit/api/v1/funding/…

Why the sandbox calls are namespaced

predicta.sandbox.* reads as wrong at the call site, which is the intention. Those endpoints answer 403 sandbox_only in live mode, and a live integration built on them should be obvious in review rather than at go-live.

The SDK does not surface venueListingId on a settlement record, which the raw response still carries. It is ${provider}:${providerMarketId} — it names the venue and hands over the venue's own id for the contract. Everywhere else on this surface a contract is a ctr_… id and nothing more.

The machine-readable contract

An OpenAPI 3.1 document covering all eighteen paths is at /openapi.json. It is checked against the app router in CI: it cannot describe an endpoint that does not exist, cannot omit one that does, and cannot name a scope a handler does not enforce.

There is also a black-box conformance runner — node scripts/conformance.mjs --base <url> --key <apiKey> — which walks the whole lifecycle over HTTP with nothing but a key. It imports nothing from the application, so what it proves is what a partner can reach. Run it against your sandbox before you write a line, and against production before you switch traffic on.

Next: the quickstart runs this flow against a sandbox key in about ten minutes.