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 { ParityClient } from '@/sdk';
const parity = new ParityClient({
baseUrl: process.env.PARITY_BASE!, // the host issued to you
apiKey: process.env.PARITY_KEY!, // pk_test_… sandbox, sk_live_… live
timeoutMs: 15_000, // per-request deadline (default)
maxRetries: 2, // GETs only (default)
});
const player = parity.forUser('your-own-player-id');- Catalogue and reporting hang off the client. Anything that acts for one end user hangs off
forUser, so theX-Parity-Userheader cannot be forgotten. parity.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.parity.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. Parity 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 parity.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. Record the order. `clientOrderId` is your idempotency key. Derive it
// from your own reservation, never from a clock: it has to survive the
// crash that loses you our response.
//
// The order comes back `reserve_pending`, which has no transition to
// `submitted` in the state machine. Nothing can send an order whose
// customer funds nobody has confirmed are held.
const order = await player.submitOrder({
quoteId: quote.quoteId,
clientOrderId: reservationId,
executionIntent: 'record_intent',
});
// 4. Reserve the AUTHORITATIVE amount in YOUR OWN ledger, then say so.
try {
await wallet.reserve(order.operatorMoney!.authorizationAmount, reservationId);
} catch (err) {
// 5a. Could not hold it. Withdraw the order and release exactly what the
// instruction says, keyed on `cancellationId`, so a lost response
// cannot become a second credit.
const { cancellation } = await player.cancelOrder(order.orderId, { reason: 'insufficient funds' });
await wallet.release(cancellation.releaseAmount, cancellation.cancellationId);
throw err;
}
// 5b. Held. This is idempotent: a timeout here is resent, not investigated.
await player.confirmReserve(order.orderId, { reserveReference: reservationId });
// → status `pending_venue_execution`. The order is cleared to execute, under
// these same state names.An order is not a fill
pending_venue_execution is a real, funded, recorded order and not a trade. A position opens when a fill arrives, and simulated on the response says whether that fill was produced locally.
A fill carries operatorMoney.actualDebit to take and operatorMoney.releaseAmount to hand back. 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.
executionIntent: 'simulate' fills immediately and creates a position. That is sandbox behaviour. See Sandbox.
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-${reservationId}`,
});
// 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 parity.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. Parity 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 / price_stale / price_moved / price_unverified | Re-quote and let the player confirm the new number. |
QuoteAlreadyUsedError | 409 quote_already_used, a subclass of ConflictError, carrying orderId | The quote was spent by a DIFFERENT order. Read orderId with getOrder, then quote afresh for whatever is still unfilled. Your own clientOrderId finds nothing. |
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
Two calls, and the second is not optional. findOrderByClientOrderId reads GET /api/v1/orders, which finds the order but does not carry settlementState. Only GET /api/v1/orders/{id}, reached through getOrder, computes it.
// 1. Find it 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 found = await player.findOrderByClientOrderId(clientOrderId);
if (!found) return schedulePoll(); // not visible yet: keep holding
// 2. Read THAT order back. settlementState lives only on this route, and a
// branch comparing undefined to 'settled' would release a hold it must keep.
const order = await player.getOrder(found.orderId);
if (order.settlementState === 'settled') {
await wallet.settle(order.operatorMoney ?? order.operatorExitMoney);
}
// 'pending', which includes 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, though not always on the same object.listEvents/iterateEvents,listSettlements/iterateSettlementsandreadFeed/iterateFeedpair up on the client.listOrders/iterateOrdersandlistPositions/iteratePositionspair up onforUser(id): the client-levellistOrdersandlistPositionshave no iterator, andforUser(id).listSettlementshas none either. - 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().confirmReserve | POST /api/v1/orders/{id}/confirm-reserve |
forUser().cancelOrder | POST /api/v1/orders/{id}/cancel |
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
parity.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.
A settlement record used to carry venueListingId, which is ${provider}:${providerMarketId}: it named the venue and handed over the venue's own id for the contract. The SDK filtered it out while the raw response still published it, which is filtering in the wrong place. It is fixed on the wire, and every endpoint publishes contractId as the same opaque ctr_… instead.
Including the deprecated feed
GET /api/v1/settlements/positions has now had the same treatment, so calling the API directly meets no venue-bearing field either. The SDK does not expose that endpoint; use listSettlements, which is keyset-paged and gap-free.
The machine-readable contract
An OpenAPI 3.1 document covering the whole /api/v1 surface 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 key before you write a line.
Do not run the full suite against a live key
It is a sandbox tool. It places a real order at step 4, and from step 15 it drives the sandbox scenario and resolution controls, which answer 403 sandbox_only for a live tenant. Against a live key it fails rather than skipping, having already traded.
--read-only is the mode that is safe to point at a live deployment. It exercises the catalogue, the reads and the refusals without writing anything.
Next: the quickstart runs this flow against a sandbox key in about ten minutes.

