API reference
HTTP and WebSocket API of the strikeabox server. Base path /api. JSON everywhere.
This page is a readable summary. The full reference, with every field, status code and error code, is spec/api.md in the repository.
- Practice amounts are integers in hundredths of a credit:
12345is 123.45. The API calls them points. - Prices are decimal strings, exact as received from Strike.
- Times are Unix milliseconds.
- Ids are UUIDs unless stated.
- Shared types (
Bet,QuotedCell,QuoteSheet,TapRequest,TapResult,LiveBox,LiveGate,MarketState) are defined inpackages/core/src/types.ts.
Errors
Every error is { ok: false, code, message, … } with a matching HTTP status. Business rejections of a tap are the exception: they are 200 {ok: false, …} (see Practice).
| Status | code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Body, query or path failed validation (unknown field, wrong type, out of range, malformed id). Endpoint-specific 400 codes are listed in the spec. |
| 401 | UNAUTHENTICATED | No valid session. |
| 403 | CSRF | A state-changing request without X-SBX-CSRF: 1 (see below). |
| 404 | NOT_FOUND | No such route or resource. Social and live routes have their own codes such as ROOM_NOT_FOUND. |
| 413 | PAYLOAD_TOO_LARGE | Body over 16 KiB. |
| 414 | URI_TOO_LONG | Request line too long. The path is not echoed back. |
| 415 | UNSUPPORTED_MEDIA_TYPE | Body is not JSON. |
| 429 | RATE_LIMITED | Too many requests. |
| 500 | INTERNAL | Unexpected failure. Details are never returned. |
Numeric inputs are bounded: out-of-range values answer 400, never 500.
Authentication
A session is an opaque random token (32 bytes, base64url), stored server-side as a SHA-256 hash. Send it as:
- the cookie
sbx_session(HttpOnly, SameSite=Lax, Secure in production, 30 days), or Authorization: Bearer <token>, for clients where cookies are blocked. A request that carries anAuthorizationheader is authenticated by it alone: a malformed or unknown bearer is401, never a fallback to the cookie.
CSRF. Every state-changing request (POST, PUT, PATCH, DELETE) to any path must carry the header X-SBX-CSRF: 1, unless it is authenticated by Authorization: Bearer. A custom header forces a CORS preflight, and CORS allows only APP_ORIGIN (with credentials). This applies to the Live and social routes too.
Client address. Per-IP limits use the TCP peer address. X-Forwarded-For is honoured only for peers listed in TRUST_PROXY. When CLIENT_IP_HEADER names a header the platform's edge sets (for example cf-connecting-ip), its value is used instead whenever it holds exactly one valid IP address; while it is unset, that header is never read.
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/session | {ref?: string} (a referral code, at most 32 characters) | 200 {token, user: Me}. Idempotent: a request that already carries a valid session gets that session's token and user back. Otherwise it creates a guest account with 1,000.00 points and a session, sets the cookie, and attributes ref to its owner if the code is valid (an invalid code never blocks sign-up). Limited to 10 new accounts per hour per IP (429 RATE_LIMITED). |
| POST | /api/session/logout | – | 204. Revokes the session and closes every WebSocket authenticated with it. |
| GET | /api/me | – | 200 Me |
| PATCH | /api/me | {displayName?, showInFeeds?, dailyStakeLimit?: number | null} (unknown fields refused) | 200 Me. 409 NAME_TAKEN; 400 NAME_INVALID, NAME_RESERVED, LIMIT_INVALID. |
Me = {
id, displayName, referralCode,
balance: { available, inPlay, at },
refillAvailable: boolean,
dailyStakeLimit: number | null, stakedToday: number,
showInFeeds: boolean, createdAt,
strike: { connected: boolean, chain?, address?, accountId?, status?: "active" | "revoked" }
}Display names: 3–20 characters [A-Za-z0-9_ .-] with no double spaces, unique ignoring case, default player-xxxx. A daily stake limit is an integer in hundredths between 1.00 and 1,000,000.00 points, or null for none.
balance.at is a per-user, strictly increasing stamp of the last balance change. Every balance the server returns carries it (Me, tap results, refill answers and the balance push), so a client can tell which of two balances is newer: the bigger at. Compare it only with other stamps of the same user.
showInFeeds is the account-level "show me by name" setting (default off): it controls the global leaderboards and the display name on public replays. Appearing in a room's feed is a separate, per-membership setting; see Social.
Markets
| Method | Path | Response |
|---|---|---|
| GET | /api/markets | 200 {network, executionNetwork, markets: MarketState[], serverTime, practiceEnabled, params}. network is the data network ("testnet" or "mainnet") that drives the practice board; executionNetwork is where Live orders execute. params carries the grid and limit parameters the client needs: COLUMN_MS, CUTOFF_MS, QUOTE_TTL_MS, VISIBLE_COLUMNS, VISIBLE_ROWS, ROW_HEIGHT, HOUSE_EDGE, STAKE_MIN, STAKE_MAX, USER_OPEN_STAKE_MAX, REFILL_TARGET, MAX_GAP_MS, STALE_MS, VOID_DEADLINE_MS, MODEL_VERSION. |
| GET | /api/market/:symbol/ticks?since=<E>&limit=<n> | 200 {symbol, ticks: [E, price][]}, ascending, with exact price strings. limit is 1–900 (default 900); default since is the last 120 s. Flagged ticks are never returned. |
| GET | /api/market/:symbol/sheet | 200 QuoteSheet, the latest signed sheet, or 503 MARKET_STALE. Each offered box is priced with the robust method: the higher of the model and the empirical touch rate. |
| GET | /api/market/:symbol/book | 200 BookView: the data network's order book, summed per grid row. |
| GET | /api/market/:symbol/stats | 200 MarketStats: the data network's 24 h statistics, funding, mark and index, open interest and latest trades. |
| GET | /api/exec/:symbol/ticks?since=<E>&limit=<n> | 200 {symbol, ticks}: the execution network's mark prices, for Live mode's board. |
| GET | /api/exec/:symbol/book | 200 BookView: the execution network's order book. |
symbol is BTC-USD, ETH-USD or SOL-USD; anything else is 400. The book, statistics and /api/exec routes answer 503 MARKET_DATA_UNAVAILABLE when the process has no market feed. None of them ever affects a quote, an outcome or a payout.
BookView = {
symbol, rowHeight,
updateId: number | null, at: number | null, // null before the first snapshot
stale: boolean, // not synced (being rebuilt), or last REST snapshot older than 20 s
best: { bid: [price, size] | null, ask: [price, size] | null }, // Strike's strings, unchanged
rows: [{ rowIndex, low, high, bid, ask }] // exact per-row sums, rows with resting size only
}
MarketStats = {
symbol, at, last, open24h, high24h, low24h, volume24h, quoteVolume24h,
mark, index, fundingRate, nextFundingTime, openInterest, // each null until Strike sends it
trades: [{ price, qty, side: "buy" | "sell", T }], // newest first, at most 20
received: { ticker, mark, openInterest, trades } // when each group was last received
}The order book is the server's copy of Strike's: the depth stream keeps the 20 best levels of each side current, and the full book is re-read from Strike's REST snapshot every 2 s. Row sums are normalised decimal strings ("0" for a side with none).
Execution network. /api/exec/* and the exec:<SYMBOL> channel serve the execution network's market, which Live mode draws. When the two networks are the same they answer exactly like the market routes and channel. When they differ, /api/exec/:symbol/ticks (same query rules) returns the execution network's accepted mark ticks from an in-memory history of the last 15 minutes kept by each api instance: after a restart it starts from the restart.
Practice
| Method | Path | Body | Response |
|---|---|---|---|
| POST | /api/bets | TapRequest | 200 TapResult (also for business rejections, ok: false); 401; 429 {ok: false, code: "RATE_LIMITED"}; 409 {ok: false, code: "IDEMPOTENCY_MISMATCH"}; 400 |
| GET | /api/bets?status=open|settled&limit=<1–100>&before=<ms>&beforeId=<uuid> | – | 200 {bets: Bet[], next: {before, beforeId} | null}: your boxes, newest first. Pass next back as before and beforeId for the next page. |
| GET | /api/bets/open | – | Same as ?status=open. |
| GET | /api/bets/by-key/:idempotencyKey | – | 200 Bet or 404. Reconciles a tap whose response was lost. |
| GET | /api/bets/:id | – | 200 Bet (own boxes only; 404 otherwise). |
| POST | /api/balance/refill | – | 200 {balance: {available, inPlay, at}, amount} or 409 {code: "REFILL_UNAVAILABLE"}. Once per UTC day, only below 1,000.00 points; it tops up to exactly 1,000.00. |
Tap request and result
TapRequest carries the cell id, the quoted m100 (multiplier × 100) and issuedAt, the quote tag, the stake in hundredths, a client-generated idempotencyKey (16–64 characters of [A-Za-z0-9_-], unique per intended tap), and an echo of the quote fields (low, high, p, S0, sigma) so the server can verify the tag without state. Optional roomId: it is honoured only if you may view that room, otherwise the box is accepted unattributed.
TapResult is either {ok: true, bet, balance: {available, inPlay, at}, duplicate} or {ok: false, code, message, freshQuote?}. freshQuote comes with QUOTE_MOVED: a signed quote, plus the S0 and sigma it was signed with, ready to echo in a new tap.
Idempotency. A tap is identified by your user and its idempotencyKey. A retry of the same tap (same box, multiplier, stake and quote) returns the original result with duplicate: true, even after the quote expired or the cell closed. The same key with a different tap is refused with 409 IDEMPOTENCY_MISMATCH and executes nothing.
Rejection codes
| Code | Meaning |
|---|---|
UNAUTHENTICATED | No valid session. |
RATE_LIMITED | Over the per-user tap bucket: 8 burst, 4 per second. |
BAD_QUOTE | The tag doesn't verify against the quote fields, or the box is unknown. |
QUOTE_EXPIRED | The quote is older than 3 s. |
CELL_CLOSED | Past the cutoff: the window starts in less than 3 s. |
MARKET_STALE | No usable tick for 3 s, or Strike's clock is more than 2 s out of step with ours. |
STAKE_OUT_OF_RANGE | Stake outside 1.00–100.00 points. |
QUOTE_MOVED | The robust re-price is more than 3 % below the quoted multiplier, or the box is no longer offered because it became too likely. Fresh quote returned when there is one. A box that became too unlikely to offer is accepted at the quoted multiplier. |
INSUFFICIENT_BALANCE | Available balance below the stake. |
DAILY_LIMIT | Over your own daily stake limit. |
EXPOSURE_LIMIT | Over your 500.00 open stake, the box's 20,000.00 potential payout, or the global 500,000.00 potential payout. |
MODE_DISABLED | Practice taps are switched off on this deployment (PRACTICE_ENABLED=false). Only new acceptances stop: open boxes keep settling and refunding, and a retry of an already accepted tap still returns it. |
The cutoff and the quote age are re-checked with a fresh clock inside the acceptance transaction, right before commit. One transaction performs the idempotency insert, balance reservation, exposure checks, ledger entries, the bet row and its settlement job.
Replays (public)
| Method | Path | Response |
|---|---|---|
| GET | /api/replays/:publicId | 200 {bet: PublicBet, ticks: [E, price][]} for a settled box; 404 REPLAY_NOT_FOUND for an unknown id or a box that is still open. |
| GET | /og/:publicId.png | 1200 × 630 PNG preview image (404, or 503 BUSY with Retry-After when the renderer is busy). |
| GET | /r/:publicId | The web app shell with preview tags for that replay. |
PublicBet never contains the internal bet id, user id, room id or idempotency key, and includes displayName only if the owner has showInFeeds on. ticks are the evidence stored with the outcome.
Strike live (session required)
Live mode executes on the deployment's execution network (STRIKE_NETWORK, testnet by default; mainnet also needs LIVE_MAINNET_ENABLED=true), and its gates and plans read that network's market only, also when practice uses another network's data. While Live is unavailable (kill switch, no builder code, mainnet not enabled), new connections and entries answer MODE_DISABLED; listing, account reads, disconnecting and closing open boxes keep working. Live routes other than /api/strike/status exist only on deployments with KEY_ENCRYPTION_KEY.
| Method | Path | Body | Response |
|---|---|---|---|
| GET | /api/strike/status | – | {network, available, reason: string | null, builderFeeBps, builderFeeCapBps, builderCode?, link?: {chain, address, accountId, status, feeShareBps, connectedAt}, gates: LiveGate[]}. network is the execution network ("testnet" or "mainnet"). builderFeeBps is the fee charged per order, builderFeeCapBps the maximum users approve. gates has one gate per market, checked for a $100 notional on the execution network's market (the plan checks your chosen notional). A gate may carry minNotionalUsd, the smallest notional Strike accepts on that market, so presets below it can be disabled. |
| POST | /api/strike/connect/challenge | {chain: "ethereum" | "solana" | "cardano", address} | {challengeId, messageToSign, feeShareBps, builderCode, expiresAt}. Rate limited: at most 5 per user and 20 per network in about ten minutes. |
| POST | /api/strike/connect/verify | {challengeId, walletSignature} | {link} |
| POST | /api/strike/disconnect | – | {ok: true, revokeHint, openBoxes}. The stored key is deleted. |
| GET | /api/strike/account | – | {balances, positions, openOrders}, read-only, from Strike. |
| POST | /api/live/boxes | {cellId, notionalUsd, idempotencyKey?, preview?, expect?: {side, size, target, stop}} | With preview: true: {ok: true, plan}. Otherwise (idempotencyKey of 16–64 characters required): {ok: true, box: LiveBox, duplicate}. If expect (the plan you reviewed) no longer matches the fresh plan, nothing is opened and the answer is {ok: false, code: "PLAN_CHANGED", message, plan}. Rejections are 200 {ok: false, code, message}. |
| GET | /api/live/boxes?status=open|closed | – | {boxes: LiveBox[]} (default open). |
| POST | /api/live/boxes/:id/close | – | {ok: true, box}: a user-started early close (reduce-only). {ok: false, code: "NOT_FOUND" | "NOT_OPEN"}. |
Live rejection codes include MODE_DISABLED, NOT_CONNECTED, LINK_REVOKED, BAD_CELL, CELL_CLOSED, CELL_CONTAINS_PRICE, NOTIONAL_NOT_ALLOWED, BELOW_MIN_NOTIONAL, SIZE_TOO_SMALL, SIZE_TOO_LARGE, MARKET_UNAVAILABLE, LIQUIDITY, SPREAD_TOO_WIDE, PLAN_CHANGED, POSITION_CONFLICT, ENTRY_UNCONFIRMED, OPEN_BOX_LIMIT, DAILY_LIMIT, RATE_LIMITED, STRIKE_UNAVAILABLE and STRIKE_ERROR. The full list with their meanings is in the spec.
plan = {
symbol, cellId, side, size, requestedNotionalUsd, notionalUsd,
mark, estEntry, entryLimit, target, stop, colStart, colEnd,
leverage, marginUsd, takerRate, builderFeeBps,
estExitAtTarget, estExitAtStop,
fees: { entry, exitAtTarget, exitAtStop }, // each { tradingUsd, builderUsd }
builderFeeUsd, estFeesUsd, estFeesAtStopUsd,
estGrossPnlAtTargetUsd, estGrossPnlAtStopUsd,
estPnlAtTargetUsd, estPnlAtStopUsd, // net of fees and spread
gate: LiveGate,
expectedFill: { avgPrice, slippageBps, fillable: "full" | "partial" | "none", levels, bookAt }
}expectedFill is an estimate: the size walked against the execution network's order-book copy on the entry side, through every level priced no worse than entryLimit (the IOC limit, which is the best opposite price). On a thin book it is often partial. none means nothing would fill within the limit, or there is no current book; the app then keeps Confirm off. Fills always come from Strike.
When a gate is closed, LiveGate.reason says why, in the server's words, naming the network where it describes the book: for example Testnet book too thin: only $120 of bids within 0.75% of mark (need $200), Testnet book too thin: best bid 20.9% below mark, No recent order-book depth, No recent mark price.
LiveBox.status is one of INTENT, SUBMITTED, OPEN, CLOSING, TARGET_HIT, STOPPED, TIME_EXIT, NOT_FILLED, REJECTED, CLOSE_FAILED, RECONCILING. LiveBox.exitReason says why an exit order closed the box: "time", "user" or "protective"; it is null while the box is open or when a bracket leg closed it.
Social
Social errors are {ok: false, code, message}. Private rooms are invisible to non-members (404 ROOM_NOT_FOUND, never 403).
| Method | Path | Notes |
|---|---|---|
| GET | /api/rooms | {rooms: RoomView[]}: public rooms and your rooms, official first, each with live presence. |
| POST | /api/rooms | {name, symbol, private: boolean} → 201 {room}, with inviteCode if private. |
| POST | /api/rooms/join | {inviteCode}, or {roomId} for a public room. 409 ROOM_FULL. |
| GET | /api/rooms/:id | {room, members, presence, feed, battles, me: {member, showInFeed}}. |
| PATCH | /api/rooms/:id/me | {showInFeed: boolean} → {me: {member: true, showInFeed}}. The per-room opt-in to appear by display name in this room's feed, member list and leaderboard. 404 NOT_A_MEMBER. |
| POST | /api/rooms/:id/leave | 204. |
| GET | /api/leaderboard?period=day|week|all&roomId=&sort=net|return | {period, sort, periodStart, periodEnd, roomId, minSettledForReturn, rows: [{rank, displayName, netPts, settled, returnPct | null}], me}. Only players who chose to appear by name are listed: showInFeeds on the global board, the room's showInFeed on a room board. |
| POST | /api/battles | {roomId?, durationMs?}: 60–600 s in 10 s steps, default 180 s. → 201 {battle} in lobby. 400 BAD_DURATION. |
| GET | /api/battles/:idOrCode | {battle}. By id, a battle in a private room is visible only to its players and the room's members (and, for a rematch, the players of the battle it follows); anyone else gets 404 BATTLE_NOT_FOUND. |
| POST | /api/battles/:idOrCode/join | {inviteCode?} → {battle}. |
| POST | /api/battles/:id/start | Creator only, with at least 2 players. |
| POST | /api/battles/:id/rematch | After the battle is final. Opens a new lobby with only you in it; the other players join it themselves. Counts towards your open battles (409 TOO_MANY_BATTLES) and battle creations (429). |
| GET | /api/referrals | {code, link, bonus, requiredSettled, referred: [{displayName, settled, bonusPaid}]}. settled counts decided (won or lost) boxes. |
| POST | /api/referrals/claim | {code}: late attribution under the same rules; 409 REFERRAL_CYCLE if the code's owner was referred by you, directly or through others. |
A battle's status is lobby, running, settling or final. A battle is final once every box accepted in its window has settled and at least 5 s have passed after the window.
A tap's optional roomId is honoured only if you may view that room. Accepted and settled boxes of room members who appear by name become feed items; settlement also advances battles and pays referral bonuses once the referred user has 5 decided (won or lost) boxes.
Health
| Method | Path | Response |
|---|---|---|
| GET | /api/health | {ok, role, db: boolean, version} |
| GET | /api/health/worker | {lastTickAgeMs: {[symbol]: number | null}, lastSchedulerRunAgeMs, dueJobs, oldestDueJobAgeMs, deadJobs, openBets, overdueBets, ledger: {ok, checkedAt} | null, liveUserStreams}. Hosted proof of background work. |
WebSocket /ws
Messages are JSON objects {t: <type>, …}. Client messages are limited to 4 KiB and rate limited; a connection holds at most 32 subscriptions. Slow consumers and peers that stop answering heartbeats are dropped. The server accepts at most 5,000 connections and 20 per client IP; beyond that the socket is closed with code 1013.
Auth: the sbx_session cookie, honoured only when the Origin header is absent, equals APP_ORIGIN or matches the request's own host; or a first message {t: "auth", token}. Anonymous connections may subscribe to market:*, exec:* and public room:* channels only. A socket authenticated by a session ends with that session: on logout or expiry it receives {t: "error", code: "SESSION_REVOKED" | "SESSION_EXPIRED"} and is closed with code 4001.
Client → server
| Message | Meaning |
|---|---|
{t: "sub", ch} / {t: "unsub", ch} | ch is market:<SYMBOL>, exec:<SYMBOL> (the execution network's market), user or room:<id>. Answered with {t: "subscribed", ch} or {t: "error", code}. |
{t: "auth", token} | Sign in on this socket. |
{t: "ping", id} | Answered with {t: "pong", id, serverTime}; used to estimate clock offset. |
{t: "presence", roomId} | Heartbeat every 15 s while a room is on screen. A connection is present in one room at a time. |
Server → client
| Message | Meaning |
|---|---|
{t: "hello", serverTime, version, authenticated} | On connect |
{t: "tick", symbol, E, p} | Every stored tick, in E order, de-duplicated (market:<SYMBOL>); every accepted mark tick of the execution network (exec:<SYMBOL>) |
{t: "market", state: MarketState} | On subscribe, on every status change and every 5 s (market: and exec: channels) |
{t: "sheet", sheet: QuoteSheet} | On every tick while the market is live (market:<SYMBOL>) |
{t: "book", book: BookView} | On change, at most 4 per second per channel, and on subscribe (market: and exec: channels) |
{t: "stats", stats: MarketStats} | On change, at most once per second, and on subscribe (market:<SYMBOL>) |
{t: "bet", bet: Bet} | Your own box changed (channel user) |
{t: "balance", available, inPlay, at} | Channel user. Drop a push whose at is older than a balance you already hold. |
{t: "live", box: LiveBox} | Your live box changed (channel user) |
{t: "strike", status: "revoked", reason} | Strike rejected your API wallet (channel user) |
{t: "presence", roomId, count, viewers} | Live presence: count is signed-in users, viewers anonymous connections |
{t: "feed", roomId, item} | item = {kind: "accepted" | "won" | "lost" | "void", displayName, symbol, m100, stake, net?, publicId, at} |
{t: "battle", battle} | Battle updates (room channel, and to the players' user channel) |
{t: "error", code} | BAD_CHANNEL, FORBIDDEN, UNAUTHENTICATED, TOO_MANY_CHANNELS, BAD_MESSAGE, … |
Pushes are hints. Clients must re-subscribe and re-fetch REST state after every connect and reconnect (/api/me, /api/bets/open, /api/market/:symbol/ticks?since= or /api/exec/:symbol/ticks?since= for the market on screen, /api/market/:symbol/sheet, /api/live/boxes), so a missed push never leaves stale state.