B2B API
Full per-endpoint reference: parameters, request/response schemas and examples.
Self-hosted integrators call these operator endpoints to quote, place and settle player bets. Every request is HMAC-signed (see Authentication), and the ones that act for a player additionally carry that player's session as Authorization: Bearer. Base URL is your operator API host; all paths below are relative to it. Amounts are decimal strings; price is a probability in (0,1).
Two dimensions, and the badge tells you which
Every endpoint on this page carries one of two badges, and they are not decoration — they are the whole difference between a call that works and a 401 session_required.
| Field | Type | Description | |
|---|---|---|---|
| HMAC | merchant | optional | Signature only. The catalogue and everything hanging off a market, the events grouping, the ticks socket, the merchant-scoped quote, and the three auth calls. These name no player, so there is no session to send and sending one changes nothing. |
| HMAC + Bearer | player | required | Signature AND the player's session token. Eighteen endpoints, listed below. The signature says the call came from you; the session says which of your players it is for. Omit the session and the answer is 401 session_required — the signature verified, so it is never a signing bug. |
Take a session with POST /b2b/v1/auth/session and {"player_id":"…"} in the body — that call is itself merchant dimension, because it is the one that produces a session rather than consuming one. It is also the only place a player id travels in a request: the X-Player-Id header is retired and a request still carrying it is refused with 400 player_id_header_retired across the whole of /b2b/v1. Full rules and the session lifetime are on Authentication.
The eighteen player-dimension endpoints, in full
| POST | /b2b/v1/auth/refresh | Slide the idle window without making a business call, and read back the player the session resolves to. |
| POST | /b2b/v1/orders/quote | Player-scoped quote — priced against that player's position and limits. Not the merchant-scoped one. |
| POST | /b2b/v1/risk/validate | Pre-flight a prospective bet against the player's limits. |
| POST | /b2b/v1/orders | Place a market order. |
| GET | /b2b/v1/orders | Order history — paginated, and exact lookup by idem_key or order_id. |
| POST | /b2b/v1/orders/limit | Rest a limit order. |
| GET | /b2b/v1/orders/limit | List resting limit orders. |
| POST | /b2b/v1/orders/limit/{limitOrderId}/cancel | Cancel a resting order and refund its reservation. |
| POST | /b2b/v1/cashout/quote | Price an early close. |
| POST | /b2b/v1/positions/close | An alias of the cash-out quote, despite the name. It moves nothing. |
| POST | /b2b/v1/cashout/submit | Close the position. This is the call that moves money. |
| GET | /b2b/v1/positions | The player's positions, open and settled. |
| GET | /b2b/v1/wallet/balance | Operator-side view of the player's balance. |
| GET | /b2b/v1/wallet/transactions | Operator-side money movements. |
| GET | /b2b/v1/settlements | Settlement outcomes. |
| GET | /b2b/v1/settlements/views | Settlements joined with the resolution and what the player held. |
| GET | /b2b/v1/disclosure/status | Whether this player has acknowledged the required disclosure. |
| POST | /b2b/v1/disclosure/ack | Record the player's acknowledgement. |
Listing markets, and paging through them
GET /b2b/v1/markets/list is the lobby feed. It pages by keyset, not by offset: pass the next_cursor you were given back as cursor, and stop when has_more is false. There is deliberately no total — counting matching rows on every page was the single slowest query in the product, and a keyset cursor also cannot skip or repeat a market when the catalogue changes mid-scroll, which an offset can.
Filters (category, subcategory, market_type, timeframe, keyword) are applied server-side and must be resent unchanged with the cursor — and that is advice, not a rule the server enforces: send a cursor from one filter with another filter's query and you get rows, not an error, positioned by a cursor that never belonged to them. limit defaults to 20 and is clamped to 100 — asking for more returns 100 rather than an error, so page on has_more and never on having received fewer rows than you asked for. A zero or negative limit is not an error either: it falls back to the default 20, so a page size computed by arithmetic that reaches zero returns twenty rows rather than none — verified live at 0 and -1. A keyword longer than 64 characters is silently truncated to the first 64 — the request succeeds and the extra characters simply never took part in the match, so a long paste searches for its own prefix and returns more than you asked for. Unknown query parameters are ignored the same way: a misspelt filter name returns the full unfiltered page rather than an error, which reads as "the filter found everything".
A page can also come back shorter than limit while has_more is still true. Markets the upstream has no live price for are filtered out after the page is read, because listing a market you would then be refused a quote on is worse than a short page. Treat has_more as the only end-of-feed signal.
An unusable cursor is not rejected — it silently restarts the feed. The value is parsed permissively and anything that does not parse becomes zero, which means "no cursor". Verified live: abc, -1, 0 and a cursor larger than any row all return the first page, byte for byte the same rows as sending no cursor at all, with has_more true. A client that corrupts or mislays its cursor therefore loops on page one forever and never sees an error. Guard for it on your side: if a page comes back with the same next_cursor you just sent, or with rows you have already seen, stop rather than continue — the feed will not tell you.
Walking the whole catalogue is a normal thing to do and it does not get slower as you go. Measured on the sandbox across 79 consecutive pages of 100: the last page cost the same server time as the first, within the noise of the measurement, which is the property keyset pagination exists for. Budget the walk on page count rather than on depth.
Over a walk that long, expect the occasional transient failure — one of those 79 pages answered 502, and the identical cursor then succeeded five times in a row. This is the one place a keyset cursor is markedly easier to live with than an offset: the retry is the same request with the same cursor and it cannot land you on a shifted window. Retry the page rather than restarting the walk.
# first page
GET /b2b/v1/markets/list?category=crypto&limit=30
→ { "items": [ ... ], "next_cursor": 242817, "has_more": true }
# next page — same filters, cursor echoed back
GET /b2b/v1/markets/list?category=crypto&limit=30&cursor=242817
→ { "items": [ ... ], "has_more": false } # next_cursor omitted when exhaustedTitles follow Accept-Language
Send Accept-Language and title and event_title come back translated. Send the full tag — zh-CN, not zh — because a title in pt-BR is not a title in pt-PT. Error msg text uses the same header but matches on the primary subtag only; the two granularities differ on purpose.
Coverage is demand-driven. A market list, detail, positions, order-history, open-orders, or settlement request carrying Accept-Language also enqueues any untranslated titles on that page — including settled markets, which only translate through this path — and a background worker translates them into all supported languages at once — measured end to end, translations appear within one to two minutes of the first view, so a page a player is actually looking at heals itself while they read it. A background sweep separately works through the whole listed catalogue. The one deliberate gap: five- and fifteen-minute books are excluded from the sweep, because they can close before any translation lands; if a player views one, the demand path still tries, but treat English as the expected state for sub-hourly titles
Reading a market refreshes it
Prices are kept current by a background sweep, and how fresh yours is depends on which band the market sits in — not on a single platform-wide number.
- The 5,000 nearest expiry~21s
Refreshed every sweep round. Anything a player would realistically trade before it settles lives here.
- The rest of ~96,000170s median · 212s max
Rotates behind the near band. Measured over a recent half hour; a far-dated market nobody has looked at sits here.
- Anything you read~3s
The lobby list, an event page and a market's detail each ask upstream to refresh what they return — and it stays on rotation for three minutes afterwards.
There is no single staleness limit to compare these against. The threshold a market is held to scales with how long it has left — one percent of its remaining life, floored at 120 seconds and capped at 1800. So 170 seconds is comfortable for a market settling tomorrow and already too old for one settling in four hours, and the middle band holds both. That is why a market nobody has read can answer market_locked at order time while your cards still show a price. Reading it moves it to the bottom band — that is the whole mechanism, and the rest of this section is its consequences.
Measured on a live market: feed age 106s before fetching its detail, 3s after — so you should not poll to keep prices warm. Requests are batched over a 200 ms window and each market has a 10 second cooldown, so hitting the same market harder than that costs you requests and buys nothing. The three-minute rotation that follows a read revisits roughly every 45 seconds and needs no further request from you, which is what keeps a price moving for a player who opens a page and thinks about it for two minutes — it matters most when your front end does not re-fetch on its own.
Placing an order is not a fast call, and the number that matters is contention on the market rather than load on the platform. One transaction prices the bet, runs the risk checks, debits the player's wallet over the network and books the position, all under a lock on that market's row. Measured on the sandbox: eight players each on their own market averaged 1.0 second per order, while seven players on the same market averaged 2.9 seconds and a p90 of 6.7. Nothing was refused and no balance went wrong in either case — the orders simply queued. Set client timeouts in the tens of seconds rather than the low single digits, and expect the popular market at the open to be the slow one. Every attempt is idempotent on idem_key, so a timeout is safe to retry with the same key — but re-sign the retry rather than re-sending it, since the nonce from the first attempt is already spent (see Authentication).
Budget your reads against the rate limit before you design the polling: 300 requests per 10 seconds (~30 rps), fixed window, counted per source IP address across every endpoint here — only /healthz and /metrics are exempt. Read that as one bucket for your entire player base, not one per player: forwarded headers are not trusted, so the address counted is the TCP peer, and if your backend proxies player traffic — the usual arrangement — every player arrives from that one server. A page polling twice a second therefore tops out near fifteen concurrent players, however many devices sit behind it. Exceeding it answers 429 with a Retry-After header whose value is the whole window, not the time actually remaining in it, so backing off by that number always clears but usually waits longer than it had to.
Read “per source IP” literally, because it is not a per-tenant quota and it does not follow your credentials. Scale out across several egress addresses and each one carries its own budget. Sit behind a shared NAT or an outbound proxy and every client behind it draws on the same one, including clients that are nothing to do with you. Size the limit against your egress topology, not against your account.
The window is anchored to your first request in it, not to the clock and not to a sliding average. Two consequences worth designing around: a burst that lands just before a boundary and again just after can put 600 requests through in a fraction of a second without a single 429, and a steady 30 rps will trip the limit anyway if it drifts even slightly above, because nothing is refunded until the window ends. Pace against the budget rather than probing for the edge.
One thing the sweep does not cover: a market only stays in the polling set while its market_status is 1. The moment it closes, is suspended or settles it drops out, and its feed age then climbs without bound. That is expected — a decided market has no price worth refreshing — but it means feed age is not a health signal on its own. Check the status before you read anything into an old timestamp, or you will chase a sweep that is working correctly.
The corollary is the part that bites: because reading is the refresh, a surface you stop reading stops being refreshed. Its feed age climbs until it passes stale_feed_lock_seconds (120 by default) and the market answers market_locked while your cards still show the last price. Measured on the sandbox by loading 75 lobby markets and then leaving the page alone: after 60 seconds the worst feed age was 106s and nothing was locked; after 120 seconds the median was 159s and 49 of the 75 were past the lock. So "do not poll" means do not poll faster than the 10 second cooldown — it does not mean read once and stop. Re-read any list a player is looking at on an interval comfortably under the lock, and treat a socket feed as a supplement rather than a replacement: ticks carry prices we already computed, they cannot refresh a market nobody is reading.
Quoting and ordering are not on that list. Neither asks for a refresh — they price from whatever the feed holds at that moment. So the read is what pulls a cold market forward, and a flow that quotes without having read first is quoting off the sweep alone. In practice a player has already opened the market, and that open did the work; it is worth knowing if you are building something that quotes without a screen in front of it.
One ordering detail matters if you automate this. The response that triggers the refresh is served from the feed as it stood before it — the fresher number lands about a second or two later and is what the next read returns. Timed against a cold market, the refresh landed in 0.9s, 2.1s and 2.1s across three attempts. A human flow never notices, because the quote is priced from the feed at quote time and nobody clicks that fast. A loop that fetches and quotes in the same breath can still price off the older feed, so put the quote after the render, not immediately after the fetch. Either way, take the price you are given rather than polling for a better one.
What a row contains
| Field | Type | Description | |
|---|---|---|---|
| market_id | string | optional | Pass this to quote, order and detail. |
| title · event_id · event_title | string | optional | The market, and the event it belongs to when it is part of one. |
| market_type | string | optional | updown | above | below | range | hit_price | event. Worth branching on for presentation — an updown market reads as UP and DOWN rather than YES and NO, even though side stays 1 and 2 on the wire. It travels on every row that names a market, not just here: positions, settlement views, resting orders, order history and the ambient trade feed all carry it, because each of those lists mixes markets and none of them can assume a single type. If you are labelling a side anywhere, read it from the row. |
| category · subcategory · timeframe | string | optional | Taxonomy and round length, e.g. crypto / altcoins / 15min. |
| open_at · close_at | string | optional | RFC3339 window. |
| market_status | int | optional | Lifecycle state. |
| market_result | int | optional | 0 unresolved · 1 YES · 2 NO · 3 void. Always 0 while a market is listed — it only becomes meaningful after settlement. |
| is_priced | bool | optional | Whether the row currently carries a usable price. False means it does not and the market cannot be traded. Branch on this rather than on price_yes yourself, but understand what it covers. A market never fed is false. So is one whose feed value is exactly 0.5 while price_yes is also 0.5 — what upstream returns when a book is empty — which means a market genuinely trading at exactly 0.50 reads false as well. That is deliberate and fail-closed: we would rather refuse a real coin-flip than quote a placeholder. And a rung of a price ladder is false while its price contradicts the ladder it belongs to: reaching a higher level implies having reached a lower one, so a rung priced above the one below it cannot be right, and both rungs are refused until it resolves. That last one is why a single outcome of an event can be untradeable while its siblings are fine. None of these latch — a market goes back to false if its book empties, and back to true when a contradicting price corrects, so re-read rather than caching the first answer. |
| display_sort_no | int | optional | Operator-controlled ordering hint. |
One warning from having got this wrong ourselves, repeatedly. Reading market_type in the obvious place is the easy part; the side wording leaks into more of a client than you would expect. Ours was wrong in five separate ways at once: a hardcoded lookup table, a mapping layer that quietly dropped the field so the correct helper received nothing, a literal side === 1 ? 'YES' : 'NO', the words baked into translated copy, and copy that did interpolate but was never passed the value. Each one rendered YES on a market whose buttons said UP.
What worked was a rule rather than a list: nothing outside one module may name a side, enforced by a scan over the whole tree. Enumerating the files that need fixing only proves the listed ones have not regressed, and the next occurrence appears somewhere nobody listed.
Grouping a market's siblings
↑ 1051.5%↑ 1239.5%↑ 1423.0%↑ 1610.5%↑ 186.5%
↓ 68.0%↓ 46.5%↓ 21.8%
Rows in the lobby carry an event_id when they are outcomes of one question — several strikes on the same underlying, or the candidates in one race. Fetching the whole set is a separate call, and it is the one to build a grouped board on.
/b2b/v1/events/{eventId}HMACEvery in-sale outcome of one event, plus the event title. Unlike the lobby feed this is not paginated — limit and cursor are accepted and ignored, and there is no has_more or next_cursor to read. Verified live: the same 16 outcomes come back for limit=2, limit=1000, limit=0, limit=-1 and a garbage cursor. Reading it also refreshes every outcome upstream in one batch, so a grouped board does not need to fan out per market to get fresh prices.
Response
| Field | Type | Description | |
|---|---|---|---|
| event_id · event_title | string | required | Echoed id and the shared question. The title may contain a blank such as "above ___" — that is upstream wording for a strike ladder, filled in by each outcome's own label rather than by us. |
| outcomes[].outcome_label | string | required | The row's short name, passed through from upstream verbatim. On a numeric ladder it carries a direction marker as part of the string — "↑ 18" is reach 18, "↓ 6" is dip to 6 — and that marker is the whole reason the set is readable. A ladder is two curves, not one: reach gets harder as the level rises, dip gets harder as it falls, so each is monotone on its own and neither is monotone against the other. Sort by the number alone and you interleave them, which produces an ordering no trader recognises and apparent contradictions that are not there. Parse the direction first, then the number; a label containing letters is a candidate name rather than a level and has no ordering at all. |
| outcomes[] | array | required | Lobby-shaped rows, same fields as an item from /markets/list, so one renderer serves both. Only in-sale statuses appear; drafts, settled and suspended outcomes are filtered out, which is why an event you know exists can answer 404. |
Errors
| not_found | 404 | No such event, or the event has no in-sale outcomes for your merchant. The two are deliberately indistinguishable. |
The response is unbounded — there is no server-side cap on how many outcomes come back, and none is needed at present: across 40 sandbox events the median is 2 and the largest is 16. Size your rendering against the shape of the data rather than a limit you can pass, because there is no limit you can pass.
Three prices, not interchangeable
price_yes0.660The probability. Render it as 66% — but a bet does not cost this. Reaching for it because it is the one that looks like a percentage is the common mistake.
price_base_yes0.684The operator's base buy price. The gap from the probability is the engine's own spread, already applied before you see the row. This is the rung to build on — it is the one field that means the same thing everywhere it appears.
price_base_yes + margin_add_pct/1000.684What a buy actually costs, and the number to put in front of a player. Compute it yourself and clamp to [0,1]. Equal to the base on this real row only because that merchant's margin_add_pct is 0 — with a margin set, this rung sits higher.
A row carries the probability and the cost separately, and they differ by your margin. Showing the wrong one understates what a bet costs. Depend on the formula, not on price_buy_*: that field is a convenience for existing integrations and is slated to change calibration or be withdrawn, so new work should read price_base_* with margin_add_pct and apply clamp01(base + pct/100) itself. The identity price_buy_* === clamp01(price_base_* + margin_add_pct/100) holds on every row today — verified live — which is what makes the migration a no-op rather than a change of numbers.
| Field | Type | Description | |
|---|---|---|---|
| price_yes | string | optional | The YES probability — the number to render as a percentage. NOT what a buy costs. |
| price_base_yes · price_base_no | string | optional | Operator base buy prices, before your margin. Build on these: add margin_add_pct/100 and clamp to [0,1] to get what a player pays. They are the one calibration that means the same thing over REST and over the socket. |
| price_buy_yes · price_buy_no | string | optional | The buy price with your margin_add_pct already applied. Convenient, but do not build new work on it: it is retained for existing integrations and will change calibration or be withdrawn once they have migrated — compute from price_base_* instead. Note also that a field of this name on a socket tick is NOT this number but the base. It is the marginal price, the cost of the next share, so do not divide a stake by it to work out how many shares an order will buy. A fill is the average across the whole order and the curve rises as the order walks up it, so that arithmetic always overstates the shares, and it overstates them more the larger the order. Measured against real fills on one sandbox market: 0.34% over at a stake of 2, 1.64% at 10, 7.44% at 50, 13.74% at 200. Call POST /b2b/v1/orders/quote for the figure you show a player before they commit — it matched the actual fill to four decimal places at every one of those sizes. |
| margin_add_pct | string | optional | Your margin, as a percentage ("5" means five points, added — not multiplied, and not basis points). Already baked into price_buy_*, so do not add it there a second time; it is exactly what you add to price_base_* and to a socket tick. |
The two buy prices do not sum to one. Buying both sides costs more than the unit they are guaranteed to return between them, and the difference is the engine's spread — it is there whether or not you add a margin. Sampled across twenty live sandbox markets with margin_add_pct at zero, the sum ran from 1.0145 to 1.0480 with a median of 1.0292. Size your own margin knowing that is already in the price, and do not read a sum above one as an arbitrage: it is the house edge, and the same holds on any market maker.
Live prices over a socket
GET /b2b/v1/ws/ticks upgrades to a WebSocket carrying price ticks. It is the answer to “how do I keep a board live without polling”, and it behaves differently enough from a typical market feed that the promises are worth stating before the payload.
- SubscriptionNone — connect and you get everything
There is no subscribe frame and no per-market filtering. Every active market's ticks arrive on every socket, and anything you send upstream is read and discarded. Filter on your side.
- AuthThe same HMAC as a REST GET — merchant dimension, no session
Signed before the upgrade, with an empty body in the canonical: ts \n GET \n /b2b/v1/ws/ticks \n \n nonce \n — six fields, so the string ends with that last newline. Ticks name no player, so no Bearer is involved and sending one changes nothing. A failed signature is refused as an HTTP error, so watch the upgrade response, not the socket.
- DeliveryBest-effort, and you can be dropped
Ticks are display state, so a consumer whose buffer fills is disconnected rather than slowing everyone down. The buffer is 1024 ticks per connection: fall that far behind and the socket is closed on you, with no error frame first. Liveness is a 60-second pong deadline — we answer your pings, and a socket that goes quiet past that is dropped too. Treat a disconnect as normal, reconnect, and re-read prices over REST rather than assuming you have every tick.
- HeartbeatServer pings every 25s; 60s read deadline
Answer pings — most clients do automatically. A socket that goes quiet for 60s is dead on our side even if your end still shows it open, which is the usual cause of a display that silently stops updating.
- Framemarket_id · price_yes · price_buy_yes · price_buy_no · ts
Prices as decimal strings, ts as epoch milliseconds. One trap: price_buy_yes/price_buy_no on a tick are the operator BASE prices, without your margin — not the same calibration as the identically-named REST fields, which have it applied. The hub broadcasts one payload to every connection and so structurally cannot reprice per merchant. Add margin_add_pct/100 yourself and clamp, exactly as you should for price_base_* over REST; `tick.price_buy_yes ?? card.price_buy_yes` is the wrong thing to write, and it is invisible on a zero margin.
The socket complements the read-refresh behaviour above rather than replacing it. Reads are what pull a cold market forward; the socket is what keeps an already-warm board moving between reads. A board built on both — REST on open, socket thereafter, REST again on reconnect — needs no polling at all.
A suspended side is not visible until you hit it
Suspension is per side: YES can be closed while NO still trades. The flags that carry it, is_suspended_yes and is_suspended_no, are on the operator and merchant views and are deliberately absent from the market payload you receive — the C-end contract does not carry internal risk signals. There is no field to check, and no combination of fields that implies it.
The practical consequence is that you learn about it from the refusal, not before it. A quote or an order on a suspended side answers market_locked, and the same code also covers past-close, a market with no anchored price, and a stale feed, so the response alone will not tell you which of the four you hit. Design for that: keep both sides selectable in your UI, let the refusal be the thing that closes one off, and word the message so it reads as temporary rather than as an error the player caused. It usually is temporary — a suspension is lifted by the operator, unlike past-close, which never clears.
Market stats, and which of them are real
GET /b2b/v1/markets/{marketId}/stats returns a 24-hour summary. It is worth reading this section before you use any of it, because the fields do not share a provenance and nothing in their names says so.
position_countreal onlyOpen positions. Always real — synthesised activity holds no positions, so nothing can inflate this.
open_price · high_24h · low_24hreal + synthesisedTaken from trade rows, real and synthesised together, over the same 24h window. Zero means the window holds no trades of either kind, not that the price was zero.
volume_24h · turnover · participant_countsynthesisedA synthesised component is added to the real figure. It is computed analytically from a market-heat function, not counted from rows, so it moves smoothly and is not a trade count.
The synthesised rows exist to give a new market a plausible public surface, and they are marked is_isolated at rest so they can never reach reconciliation or settlement. Your ledger and ours agree regardless of what this endpoint reports. The line to hold is simply that these are presentation figures: do not drive risk limits, liquidity decisions or your own reporting from them, and do not reconcile against them. Everything you actually need for that lives in /orders, /positions and /settlements, which are real throughout.
Two behaviours follow from how the rows are produced. They are generated when a market is first opened, so a market nobody has looked at yet returns zero for the three price fields and fills in shortly after the first visit — a fresh market answering 0 is not an error. And the whole blend is gated: with the ambient engine, the compliance switch, your merchant setting or your jurisdiction turned off, every field above falls back to real-only and the same market reports far smaller numbers. Neither transition means anything changed about the market itself.
Get a quote
There are two quote endpoints and they are not interchangeable. The one below is merchant-scoped: the merchant comes from the signature and no player is involved, so it is what you call to render prices in a catalogue or lobby — and it is why this one carries the HMAC badge rather than HMAC + Bearer. To quote for a specific player before placing their order, call POST /b2b/v1/orders/quote instead — it takes market_id in the body rather than the path, requires the player's session, and prices against that player's position and limits. Both are stateless previews; neither reserves anything.
- max_price — the only bound that is enforced
- crossing it costs nothing while nothing is being submitted — the ceiling is checked once, at execution
- quote taken — a reading, not a reservation
- submit — repriced here, and inside the ceiling, so it fills
/b2b/v1/markets/{marketId}/quoteHMACPrice a prospective bet before committing. Returns the base price, your margin and the marked-up price, plus the resulting shares and cost. The quote is a stateless preview — it reserves nothing and expires nothing. The order is repriced at execution, so carry max_price, not quote_id, if you need protection.
Body
| side | int | required | 1 = YES, 2 = NO. |
| shares | string | optional | Desired share count. Provide exactly one of shares or stake; at least one must be positive. A value that is present but unparseable is rejected rather than treated as zero. |
| stake | string | optional | BASE budget to spend; the operator solves for shares, then adds your margin on top. Same convention as the order endpoint. |
POST /b2b/v1/markets/mkt_1/quote
{
"side": 1,
"stake": "10.00"
}Response
| Field | Type | Description | |
|---|---|---|---|
| quote_id | string | required | Reference for your own logs. The order endpoint does NOT accept it — quotes reserve nothing and lock nothing. |
| market_id | string | required | Echoed market. |
| side | int | required | 1 = YES, 2 = NO. |
| shares | string | required | Shares this quote buys. |
| base_price | string | required | Fair probability before margin. |
| margin_add_pct | string | required | Margin added, percent (0–100). |
| price | string | required | Final price per share (probability) after margin. |
| cost | string | required | Total cost = price × shares. |
| odds | string | required | Decimal odds equivalent. |
| currency | string | required | Quote currency. |
{
"quote_id": "qt_a1b2c3",
"market_id": "mkt_1",
"side": 1,
"shares": "18.05",
"base_price": "0.5400",
"margin_add_pct": "2.50",
"price": "0.5540",
"cost": "10.00",
"odds": "1.805",
"currency": "EUR"
}Errors
| market_not_open | 409 | Market is not accepting bets. |
| market_locked | 409 | Four different causes share this code, and the difference matters when you decide whether to retry. The side you asked for is suspended; or the market is past its close_at — already shut, not approaching it, checked against the clock on every request so a lagging cron cannot leave a window open on a decided outcome; or the market has no anchored price, meaning it has never been fed a real one or its upstream book is empty and reports the 0.5 placeholder; or its price feed has gone stale and quoting from the last anchor would be quoting a price that no longer exists. Do not expect feed_age to explain a refusal: the no-anchor case is checked first and reads as freshly fed, so a market rejected one second after a feed frame is normal, not a bug. Everything except past close can clear on its own and a retry is reasonable. Past close never clears — treat it as terminal and stop. |
| capacity_exceeded | 409 | No room left on this side of the book. |
Place an order
/b2b/v1/ordersHMAC + BearerCommit a bet for the player. Debits the wallet, allocates shares and returns the booked position. Idempotent on idem_key, scoped to (merchant, player) so two players cannot collide on the same key — retrying returns the original order, never a duplicate. The balance in a replayed response is read fresh at replay time, so it reflects the wallet now rather than the wallet as it stood when the order was first booked. A replay is matched on the key alone: the request body is not compared, so reusing a key with a different market, side or stake silently returns the first order rather than rejecting. Nothing in the response marks it as a replay either — a repeated order_id is your only signal. Derive the key from the bet, not from the session or the UI action, or a reused counter will look like a placed order that never happened.
Body
| market_id | string | required | Target market. |
| side | int | required | 1 = YES, 2 = NO. |
| shares | string | optional | Share count to buy — RECOMMENDED, because the quantity is the part you control: it is floored to 6 decimals and booked exactly. The money is not equally exact. Submit re-prices rather than honouring the quote, so the order's stake differs from the quoted cost in the low digits, and the wallet is then debited that stake rounded to the currency's minor unit. Measured: a quote of 1.76185283324911 booked a stake of 1.76187882 and debited 1.76. Show the player the quote as an estimate, and reconcile against the order and the wallet ledger, not against the number you quoted. Provide either shares or stake, never both (sending both is rejected). |
| stake | string | optional | BASE budget. The operator solves shares from this base amount, then adds merchant margin on top (final debit = base + margin). Do NOT pass a margin-inclusive quoted cost here — that double-charges. Prefer submitting the quote's shares. |
| max_price | string | optional | Slippage guard. Compared against the average margin-inclusive price actually paid, not the marginal price at the moment of execution — a large order walks up the curve, and checking the margin would let it push past your ceiling on the way. Use the quote's margin-inclusive price, which is the same basis. One consequence: the price field in the response can read a hair above your max_price. Both figures are rounded to 8 places; they differ because the rounding lands on opposite sides of the margin. The guard rounds the base average and then applies your margin, while the stored price applies the margin and then rounds — so the last digit can disagree. A cap of 0.99 filling at 0.99000001 is that, not a breach — verified live, alongside a cap at half the market answering price_moved. |
| idem_key | string | required | Your key for this order. Unique per (merchant, player) — two of your players may use the same value without colliding. Required; an empty value is a 400. Never reuse a key for a different order: a repeat returns the ORIGINAL order and the new parameters are ignored silently. |
POST /b2b/v1/orders
{
"market_id": "mkt_1",
"side": 1,
"shares": "20.0000000000",
"max_price": "0.56",
"idem_key": "ord-2026-07-22-abc123"
}Response
| Field | Type | Description | |
|---|---|---|---|
| order_id | string | required | Booked order identifier. |
| market_id | string | required | Market the order is on. |
| side | int | required | 1 = YES, 2 = NO. |
| price | string | required | Executed price per share (probability). |
| qty | string | required | Shares allocated. |
| stake | string | required | Amount debited. |
| currency | string | required | Order currency. |
| order_status | int | required | Lifecycle state as a small integer, not a string: 2 = money leg undecided (debit sent, outcome pending), 3 = filled, 4 = rejected. A booked order comes back as 3. The column allows other values historically, but no code path writes them — treat 2, 3 and 4 as the complete set and branch on the number, never on a name. 4 is also how an empty wallet surfaces on the self-hosted path: a clean 402 from your bet hook voids the reservation and the response is HTTP 200 with order_status 4 and qty and stake of "0" — a soft reject, not an HTTP error. A client that only checks the status line will show a broke player a successful bet. |
| balance_after | string | required | Player wallet balance after the debit. Authoritative only when balance_pending is false — while the money leg is undecided this is a provisional reading, not the wallet. |
| balance_pending | bool | required | True exactly when order_status is 2: the debit went to a remote wallet and its outcome is not yet decided, so the money may not have left the player's balance at all. Do not render balance_after to the player while this is true — re-read the wallet once the order reaches 3 or 4, both of which report false. To watch for that transition, poll GET /orders?idem_key=<your key> rather than re-sending the placement. |
{
"order_id": "ord_77f0",
"market_id": "mkt_1",
"side": 1,
"price": "0.5540",
"qty": "18.05",
"stake": "10.00",
"currency": "EUR",
"order_status": 3,
"balance_after": "90.00",
"balance_pending": false
}Errors
| insufficient_balance | 409 | Hosted-wallet path only. With a self-hosted wallet an empty wallet is never an HTTP error: your hook's clean 402 voids the reservation and the answer is HTTP 200 with order_status 4 and qty and stake of "0" — the soft reject described above. Branch on order_status rather than the status line and both paths read the same. |
| price_moved | 409 | Price exceeded max_price before execution. |
| position_limit | 409 | Player position cap reached. |
| market_locked | 409 | Four different causes share this code, and the difference matters when you decide whether to retry. The side you asked for is suspended; or the market is past its close_at — already shut, not approaching it, checked against the clock on every request so a lagging cron cannot leave a window open on a decided outcome; or the market has no anchored price, meaning it has never been fed a real one or its upstream book is empty and reports the 0.5 placeholder; or its price feed has gone stale and quoting from the last anchor would be quoting a price that no longer exists. Do not expect feed_age to explain a refusal: the no-anchor case is checked first and reads as freshly fed, so a market rejected one second after a feed frame is normal, not a bug. Everything except past close can clear on its own and a retry is reasonable. Past close never clears — treat it as terminal and stop. |
| player_forbidden | 403 | Player is blocked from trading. |
| amount_too_small | 422 | The order is too small to charge honestly and is refused rather than rounded — a stake that floors to zero cents, a cost under one minor unit, a share count below 0.01, or a quantised charge that already equals the most those shares could pay out. Change the size; retrying the same numbers cannot succeed. Note the 422. |
Get wallet balance
/b2b/v1/wallet/balanceHMAC + BearerRead the operator's view of the player's available and reserved balance in the account currency. The player comes from the session, not from a parameter. Sign with an empty body — this is the canonical whose string ends with a newline, and the golden vector for it is on Signature self-check.
Headers
| Authorization | string | required | Bearer <session_token>. Identifies the player. Presented, not signed — it is not one of the six canonical fields. |
GET /b2b/v1/wallet/balance
Authorization: Bearer <session_token>
# body: "" (empty — sign the empty string, and note the canonical then
# ends with a newline, which shells strip for free)Response
| Field | Type | Description | |
|---|---|---|---|
| currency | string | required | Account currency. |
| available | string | required | Spendable balance. |
| frozen | string | required | Reserved / in-flight amount. |
| balance | string | required | Total = available + frozen. |
{
"currency": "EUR",
"available": "90.00",
"frozen": "0.00",
"balance": "90.00"
}Errors
| not_found | 404 | On the self-hosted path this is almost always YOUR 404, relayed. This read is the only per-player endpoint that leaves the operator and asks your wallet hook: a 404 from GET /wallet/balance comes back here as not_found, unchanged in status. It does not mean the operator lost the player — a player-scoped request has already resolved that player before the handler runs, which is exactly why /positions, /orders, /settlements and /wallet/transactions keep answering 200 with empty lists for the same session while this one 404s. That asymmetry is the signature of the fault: look at your own hook, not at ours. Outside production the reason field carries a snippet of your hook's response body, which is where your own code (player_not_found, wallet_not_found, …) will be. On the hosted path this code is effectively unreachable — the operator-side wallet is created on demand. |
| unauthorized | 401 | Bad signature, unknown key, or timestamp skew. Session faults are a different pair of codes on the same status — session_required when you sent no Bearer, session_invalid when the one you sent has lapsed, been kicked or belongs to another merchant. Both mean the signature verified, so read the code before you touch your signer. |
Positions and closing early
Placing an order is half the lifecycle. These three complete it. Closing is two calls: quote first, then submit. The quote does not lock the price — the close is repriced inside the transaction that executes it, exactly as a buy is. What protects the player is min_proceeds: pass the floor they agreed to and a close that would pay less is refused, which is the mirror of max_price on a buy. Omitting it is not a neutral default — it turns the protection off.
quote_id is required on submit — without it you get 400 bad_request, verified live. Its job is idempotency, not price: the key is cashout-<quote_id>, so submitting the same quote twice replays the first result rather than closing again. Take a fresh quote for every submit, or a player closing a position in two halves will find the second call quietly returning the first half's numbers.
One name to watch: POST /b2b/v1/positions/close is an alias of the quote endpoint, not the close. It returns a quote and moves nothing. The call that actually closes is /cashout/submit.
/b2b/v1/positionsHMAC + BearerList the player's positions, open and settled. Sign with an empty body. market_title rides along on each row because a settled market is no longer in the catalogue and cannot be looked up afterwards.
Response rows
| position_id | string | required | Pass this to the cash-out calls. |
| market_id | string | required | The market. |
| market_title | string | required | Denormalised on purpose — see above. |
| market_type | string | required | Denormalised for the same reason, and needed for the same job as everywhere else: on an updown market the two sides are UP and DOWN. A holding is exactly where getting this wrong shows — the market page says UP and the portfolio would say YES about the same position. |
| side | int | required | 1 = YES, 2 = NO. |
| qty | string | required | Shares held. |
| avg_price | string | required | Per-share money the player paid, in their currency. Multiply by qty for cost basis. Not a percentage — the field beside it that is one is price_yes. |
| realized_pnl | string | required | Realised profit and loss so far. |
| position_status | int | required | 0 held · 1 closed out · 2 settled · 3 refunded. Note that held is 0, not 1 — filter on === 0 for what the player still owns, and do not carry over the numbering from order_status, which counts differently. 1 is a voluntary exit through cash-out; 2 is the market resolving YES or NO; 3 is the market being voided, which returns the stake rather than paying an outcome. |
| updated_at | string | required | RFC3339. Last state change. |
/b2b/v1/cashout/quoteHMAC + BearerPrice an early close without committing to it. Returns a quote_id that submit requires — it is the idempotency key, not a price lock. Also reachable as POST /b2b/v1/positions/close, which is the same handler despite the name. Note the two calls do not report failure the same way: this one uses real HTTP status codes (an unknown position answers 404), while submit puts business refusals in a 200 body.
Body
| position_id | string | required | From the positions list. |
| shares | string | optional | How much to quote for. Same rule as submit: only a positive value strictly below your qty quotes a partial — omitting it, 0, or more than you hold all quote the whole position. Quote and submit read this identically, so a quote that looks like a trim will submit as a full close if the number is off. |
Response
| quote_id | string | required | Hand back to submit — but it identifies the attempt, it does not hold the price. Submit re-prices, and the idempotency key is derived from this value, which is why submit refuses an empty one. Measured on the sandbox: a quote of 0.5937307 for 56.51 filled three seconds later at 0.60808001 for 57.96 — better for that player, but the direction is not guaranteed. Send min_proceeds if the number you showed has to hold. |
| cashout_price | string | required | Per-share money in the player's currency, not a percentage — 0.2648 means 0.2648 EUR a share. It is a reading rather than a reservation, and it is the MARGINAL price, so qty × cashout_price is not the proceeds. Measured on one sandbox position while the quoted price barely moved (0.26487 down to 0.26483): selling 3.48 shares overstates by 0.27%, 17.4 by 1.37%, 43.6 by 3.47%, 87.1 by 7.10% and the whole 174.2 by 14.81%. Read est_payout. |
| mark_price | string | required | Per-share money, same units as cashout_price; the gap between them is the spread. Render it as money — this is what a share is worth, not the chance of anything. |
| est_payout | string | required | Total proceeds for the whole quantity, integrated along the curve rather than multiplied — this is the number to put in front of a player, and the only one that matches what lands. The est is load-bearing. Compare it against the payout in the submit response rather than assuming they match. |
| est_pnl | string | required | Estimated profit and loss. |
/b2b/v1/cashout/submitHMAC + BearerClose the position. This is the call that moves money. A business refusal comes back as ok:false with a reject_code rather than as an error status, so branch on ok before anything else. Concurrency lands in that same ok:false channel: closes racing on one position leave the winner with a payout and answer every loser 200 with reject_code already_closed. Measured live, six concurrent closes credited the wallet exactly once. Treat already_closed as final for that attempt rather than retrying — the position is genuinely gone, and a retry needs a fresh quote anyway.
Body
| position_id | string | required | From the positions list. |
| quote_id | string | required | The quote you showed the player, from /cashout/quote. Required — omitting it is a 400, not a close at the current price, because the idempotency key for the whole call is derived from it (cashout-<quote_id>). Take a fresh quote per attempt: reusing one returns the earlier result instead of closing more. |
| shares | string | required | How much to close. Treated as a partial close only when it is positive and strictly less than the position; omitting it, sending 0, or sending more than you hold all close the whole thing, and none of those is an error. So a size computed by arithmetic — remaining minus already-closed, or a percentage that rounds a shade high — silently liquidates the position instead of trimming it. Send an explicit positive number below your qty, and treat 0 as a case to catch before the call. A negative value is rejected. |
| min_proceeds | string | optional | Slippage floor. The close is refused rather than executed below this. Send it whenever you showed the player a number. |
Response
| ok | bool | required | Check this first — a refusal is not an error status. |
| reject_code | string | optional | Why it was refused, when ok is false. |
| credit_pending | bool | required | ok true says the close was accepted and the position reduced; this flag says whether the money has landed. True means the credit is still in flight to the wallet — the payout is not in the balance yet. |
| payout | string | required | Proceeds credited. |
| realized_pnl | string | required | Cumulative realised profit and loss for the whole position, not this close — summing it across closes double-counts. Per-close ledgers read leg_realized_pnl. |
| leg_realized_pnl | string | optional | This close's own PnL increment, the number that is safe to add up. Omitted entirely on an idempotent replay, where it can no longer be rebuilt — absence means unknown, never break-even. |
| position_status | int | required | 0 held, 1 closed out, 2 settled, 3 refunded. Settled and refunded split on the ruling, not on the outcome: a void sends every position to 3, while winners and losers both land on 2. So 2 does not mean the player was paid — pair it with is_win, and take the amount from the settlement. |
| transaction_id | string | required | Ledger reference for reconciliation. |
Response
| Field | Type | Description | |
|---|---|---|---|
| ok | bool | optional | Branch on this first. false means the close did not happen, and the HTTP status is still 200 — a business refusal is not a transport error, and a 200 body carries the data bare, with no code field to check (only error responses carry {code, msg, rid}). Verified live against an unknown position_id. |
| reject_code | string | optional | Why it was refused, when ok is false. Six values, all confirmed in the service source: not_found · market_locked · invalid_shares · already_closed · amount_too_small · slippage. The first two share their names with HTTP-status error codes but arrive here in the 200 body instead, so match on the field rather than on the name alone. |
| credit_pending | bool | optional | The flag that separates accepted from credited. The position is reduced the moment ok is true, but while this reads true the payout has not landed in the wallet — so the UI has three states, not two: refused (ok false), accepted with money in flight (show the payout as processing, and the balance does not include it), and credited (credit_pending false). Announcing "credited" on ok alone shows players money that has not arrived. |
| payout · cashout_price | string | optional | Only meaningful when ok is true. payout is floored to the currency's minor unit at the source, so it is never rounded up. |
| realized_pnl | string | optional | Cumulative for the position, not for this close: close a position in two halves and both responses carry the running total, so a ledger that adds this field per close double-counts every leg before the last. It answers "how has this position done so far", nothing else. |
| leg_realized_pnl | string | optional | The per-close increment — the field to sum. On an idempotent replay it is omitted from the response entirely rather than sent as "0", because the position state it was computed from no longer exists; a missing value means unknown, and booking it as zero writes a break-even leg that never happened. |
| position_status | int | optional | Read back from the row rather than assumed, so a partial close reports the position as still open. |
| transaction_id | string | optional | cashout-<quote_id> — the idempotency key, echoed so you can reconcile a replay against the original. |
Errors
| amount_too_small | 200 | Proceeds would floor below one minor unit. Nothing is written; the residue is paid out at settlement instead. |
| slippage | 200 | Repriced proceeds fell below the min_proceeds you sent. This is the protection working — it is not an error to retry blindly. |
| already_closed | 200 | Nothing left to sell, including every loser of a concurrent race. |
| market_locked | 200 | Suspended, past close, unanchored, or the feed went stale — closing is gated on feed freshness exactly as buying is, so a stalled feed cannot be sold into at a pre-stall price. |
Limit orders
Two things here differ from an exchange and will bite if assumed away. A limit buy debits the wallet the moment it is placed, not when it fills — the seamless wallet model has no freeze primitive, so resting an order costs the money up front, and a cancel or expiry returns it as a refund. And your limit_price is a bound, not the traded price: the fill executes at the execution-average price at match time, and the limit only gates it — a buy never pays above it and a sell never receives below it, so a buy that fills usually fills a little better than the reservation, and the difference between what was reserved and what the fill cost comes back as a second ledger entry (lofillrefund-<id>). Reconciliation has to expect that second entry. Fills are also all-or-nothing — there are no partial fills in the current version, so an order is entirely unfilled until the moment it is entirely filled.
/b2b/v1/orders/limitHMAC + BearerRest an order at a price. A buy debits paid_amount immediately, and that reservation is a ceiling rather than the price: a fill executes at the market and the unused remainder comes back, so a cancel and a fill both return money, just different amounts. Measured live — 4.85 reserved, filled for 4.71, 0.14 refunded, net spend equal to the order stake to the cent. Reconcile against the order stake, not against what you reserved. Idempotent on idem_key, with the same (merchant, player) scoping as market orders.
Body
| market_id | string | required | The market. |
| side | int | required | 1 = YES, 2 = NO. |
| order_action | int | required | 1 = buy, 2 = sell. |
| limit_price | string | required | The worst price you accept, not the price you get. The matcher fills only once the executable average crosses it — a buy at or below, a sell at or above — and then executes at that average, so a buy can fill cheaper than the limit but never dearer, with the difference refunded as lofillrefund-<id>. Bounded to 0.01–0.99 — outside that it answers invalid_price, verified live at 0.005 and 0.995. The bounds are not cosmetic: a limit at 0 or 1 is a claim on a settled outcome, not a bet on an open one. |
| shares | string | required | Share count to rest. |
| expires_at | string | required | RFC3339. Required — there is no open-ended resting order. Pick the market close if you want the longest life. |
| idem_key | string | required | Same rules as a market order: unique per (merchant, player), never reused for a different order. |
Response
| limit_order_id | string | required | Pass this to cancel. |
| paid_amount | string | required | What the buy just reserved — L x shares, and the exact figure a cancel or expiry refunds. Reconcile on this rather than recomputing it: the matcher gates fills against the stored value, which can differ from your arithmetic by a tick once DB precision is applied. A sell reserves nothing and reports 0. |
| status | string | required | open on a successful rest. |
| filled_qty | string | required | 0 while the order rests, then exactly shares at the fill — one jump, no value in between, because fills are all-or-nothing. |
| created_at | string | required | RFC3339, and the same value the list endpoint reports for this order. |
/b2b/v1/orders/limitHMAC + BearerList the player's limit orders. Sign with an empty body. Returns { list: [...] }. Open ones only, unless you pass status=all — and status=all requires a market_id, because a player's full history across every market is unbounded; asking for it without one is a 400. The default matters more than it sounds: an order that fills leaves this list, so polling here to watch for a fill sees the order vanish rather than turn filled. Watch /positions or /orders for the fill instead.
Response rows
| limit_order_id | string | required | Pass this to cancel. |
| status | string | required | open | filled | cancelled | expired. A string here, unlike order_status on a market order, which is an integer. |
| limit_price | string | required | The resting price. |
| shares | string | required | Size rested. |
| filled_qty | string | required | Constant 0 or exactly shares, never anything between — there are no partial fills in the current version. |
| expires_at | string | required | RFC3339. |
| market_title | string | optional | Present when known; omitted rather than blank. |
| market_type | string | optional | Same treatment as the title. Read it to label the side: a resting order list mixes markets, so there is no single type the page can assume. |
/b2b/v1/orders/limit/{limitOrderId}/cancelHMAC + BearerCancel a resting order. The refund is the full paid_amount reservation, recorded as ledger entry lorefund-<limitOrderId> — not a limit_price × shares recomputation, which can differ by a tick. There is no partially-filled case to reason about: fills are all-or-nothing, so an order you can still cancel is entirely unfilled and everything it reserved comes back. Sign with an empty body — the id is in the path, so it is covered by PATH in the canonical.
Settlements and the ledger
The last two reads close the loop. Both are GETs signed with an empty body.
This endpoint reflects the operator-side ledger only. Under the zero-custody rule the operator neither holds nor moves your players' funds: with a self-hosted wallet every debit and credit happens in your ledger via the hooks, so nothing is written here and the endpoint returns an empty list forever — verified on a live merchant whose hook is enabled. It is populated in the hosted-wallet model, where the reference backend holds the wallet. Reconcile against your own ledger, not this one, unless you are hosted.
/b2b/v1/wallet/transactionsHMAC + BearerMoney movements as recorded on the OPERATOR side. Read the note below before building against this — for a self-hosted wallet it is empty by design, and your own ledger is the record. Where it is populated, transaction_id ties a row back to the call that produced it; cash-out returns the same id.
Response rows
| transaction_id | string | required | Ledger reference. Matches the id returned by cash-out. |
| tx_type | int | required | 1 = bet debit, 2 = payout credit, 3 = refund. Three values, nothing else. |
| amount | string | required | Always positive — the direction is tx_type, not the sign. |
| balance_after | string | required | Operator-side balance after this movement. |
| market_id | string | optional | Present when the movement belongs to a market. |
| created_at | string | required | RFC3339. |
/b2b/v1/settlementsHMAC + BearerSettlement outcomes for the player's markets. A settlement in flight is a normal state, not an error — poll rather than treating a non-final status as a failure.
Response rows
| settlement_id | string | required | Settlement reference. |
| market_id | string | required | The market that settled. |
| payout | string | required | Amount paid to the player. |
| settle_status | int | required | 0 = settling, 1 = paid, 2 = suspended, 3 = disputed. Only 1 is final in the ordinary sense. |
| created_at | string | required | RFC3339. |
/b2b/v1/settlements/viewsHMAC + BearerThe settlement joined with how it was resolved and what the player held. Note the shape: this is NOT the flat row above with extra keys — the settlement object is nested under a settlement field, and the resolution and position facts sit beside it.
Top level
| settlement | object | required | The settlement row, nested. Carries settlement_id, market_id, payout, currency, settle_status and created_at as above, plus ops_settlement_id, suspend_type, retry_count and paid_at. |
| market_title | string | required | Denormalised — a settled market has left the catalogue. |
| market_type | string | required | Denormalised for the same reason as market_title. Read it before you label the sides: on an updown market the two sides are UP and DOWN, and rendering them as YES and NO contradicts what the player saw while trading. Values are updown, above, below, range, hit_price and event; only updown renames the sides. |
| market_result | int | required | Which side the market resolved to. |
| resolved_side | int | optional | The winning side, 1 or 2 — null on a void, since a void has no winner. Do not type this as required: a voided market is the one case that will break a strict parser, and it is also the case you least want to fail on, because the refund still has to be reconciled. |
| is_void | bool | required | True when the market was voided rather than resolved. |
| is_win | bool | required | Whether this player's side won. Derived for you — do not recompute it from player_side and resolved_side, because a void is neither. |
| player_side | int | required | The side the player held. |
| position_qty | string | required | Shares held at settlement. |
| twap_price | string | optional | The reference price the resolution used. |
| resolved_at | string | required | RFC3339. When the market resolved. Always earlier than the settlement's paid_at — verified across every settled row on the sandbox — but the gap is wide and not something to build a timeout around: measured at 51 seconds at the fastest, a median near five minutes, and a tail past five days on rows that needed intervention. Treat settled-but-unpaid as a normal state and poll; do not alert on age alone. |
Inside settlement
| ops_settlement_id | string | required | The operator-side id. Quote this when raising a settlement with us. |
| suspend_type | int | optional | Why it is suspended, when settle_status is 2: 0 = retry exhausted, 1 = reconciliation mismatch, 2 = conservation assertion failed, 3 = manual review, 4 = other. Null when not suspended. |
| retry_count | int | required | Payout attempts so far. |
| paid_at | string | optional | RFC3339. When the payout landed; null until it does. |
Response envelopes
The read endpoints do not share one list shape. A generic list handler written against any single one of them will break on the others, so check this table before writing the first parser. Every shape below was read off the live API rather than inferred.
| Field | Type | Description | |
|---|---|---|---|
| GET /markets/list | { items, next_cursor, has_more } | required | Keyset pagination. Pass next_cursor back as cursor and stop on has_more — there is no total, by design, because counting the catalogue on every page is what made it slow. |
| GET /positions | [ … ] | required | A bare JSON array — no wrapper at all. This is the one that catches people, including us. |
| GET /orders/limit | { list, total, limit, has_more } | required | Wrapped, and the key is list rather than items. Paginated: reads limit (1..200) and offset. status is open (the default) or all; all requires market_id, and the open branch answers total = rows on the page with has_more always false. |
| GET /orders | { items, total, limit, has_more } | required | Order history. Paginated and filterable — limit, offset, status, plus exact lookup by idem_key or order_id. See the note below. |
| GET /wallet/transactions | { items, total, limit, has_more } | required | Same shape, but unlike /orders it reads no pagination parameter — a fixed window of the most recent 100 rows. |
| GET /settlements | { items, total, limit, has_more } | required | Same again. |
| GET /settlements/views | { items, total, limit, has_more } | required | Same again, with the operator-side fields on each row. |
| GET /wallet/balance | { currency, available, frozen, balance } | required | A flat object, not a list. |
Three kinds of pagination coexist here. The lobby uses a keyset cursor because it scans a large catalogue. /orders and /orders/limit use conventional limit/offset. The remaining three per-player reads do not paginate at all.
GET /orders reads limit (1..200, default 100 — echoed back in the envelope), offset (non-negative) and status (the integer states 1..4, not a name). Invalid values are an explicit 400 rather than a silent fallback — limit=abc or status=open refuses instead of handing you 100 rows you did not ask for. It also does exact lookup: ?idem_key= (checked first) or ?order_id= returns that one order as a one-row list, and a miss is an empty list with total: 0, never a 404 — "not found" and "not booked yet" call for the same handling on your side. The idem_key lookup is the recovery path for an undecided order: when a placement answers order_status: 2, poll GET /orders?idem_key=<your key> until the order reaches 3 or 4 instead of re-sending the placement.
The three remaining per-player reads — /wallet/transactions, /settlements, /settlements/views — read no pagination parameter at all. They return the most recent 100 rows. Sending limit=2, offset=1 or page=2 returns a byte-identical response, verified against the live API. Their limit field is the constant 100 — not what you asked for, and not the number of rows you got.
This matters for one field. On the lobby, has_more: true means call again with the cursor. On /orders it is offset-aware (offset + rows returned < total), so a "load more" control wired to it works. On the three fixed-window reads it means there are older rows and this endpoint cannot reach them — it is total > rows returned, nothing more. A "load more" control wired to one of those will call the same URL and render the same 100 rows forever. Once a player passes 100 records there, treat the older history as unavailable and reconcile from your own ledger — or, for orders specifically, page through /orders with offset.