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
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.
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 theX-Predicta-Userheader cannot be forgotten. predicta.environmentis 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.rateLimitcarries 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.
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 pointThe 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.
// 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
// 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
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.
| Class | When | What to do |
|---|---|---|
ReconciliationRequiredError | 409 reconciliation_required | HOLD the authorization. Not a rejection. |
TimeoutError | Your deadline elapsed | Hold. The order may have been accepted after you stopped listening. |
ConflictError | 409 quote_expired / quote_already_used / price_stale | Re-quote and let the player confirm the new number. |
SandboxOnlyError | 403 sandbox_only | You are on the learning path. Move to the instruction contract. |
InsufficientScopeError | 403, carries required | Provision a key with that scope. Retrying never helps. |
RateLimitError | 429, carries retryAfterSeconds | Wait the server’s own figure, not a guess. |
AuthenticationError | 401 | The key is absent, unrecognised or revoked. |
NotFoundError | 404 | Includes “not yours”, deliberately — the two are indistinguishable. |
InvalidRequestError | 400 / 422 | Fix the payload or the stake. |
MoneyIntegrityError | An amount did not decompose | Do not book it. Escalate. |
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
// 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.
clientOrderIdmakes 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
nextCursoris 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 onitems.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
| Call | Endpoint |
|---|---|
listEvents / iterateEvents | GET /api/v1/events |
getEvent | GET /api/v1/events/{id} |
listCategories | GET /api/v1/categories |
imageUrl | GET /api/v1/images/{id} |
forUser().quoteBuy / quoteSell | POST /api/v1/quotes |
forUser().submitOrder | POST /api/v1/orders |
forUser().getOrder | GET /api/v1/orders/{id} |
listOrders / findOrderByClientOrderId | GET /api/v1/orders |
listPositions / iteratePositions | GET /api/v1/positions |
forUser().getPosition | GET /api/v1/positions/{id} |
listSettlements / iterateSettlements | GET /api/v1/settlements |
reconciliationReport | GET /api/v1/reconciliation |
readFeed / iterateFeed | GET /api/v1/reconciliation/feed |
sandbox.balance / sandbox.movements | GET /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.

