Browser and backend protocol
This document explains how the browser talks to the backend: the REST API under/api
and the WebSocket at /ws. The contract itself is api/openapi.yaml;
when this document and the spec disagree, the spec wins and this document is wrong. It
describes the Phase 1 contract (kickoff commit 7571e68), the Phase 2 account
additions (kickoff commit d44a1c0) and the Phase 3 trading additions (kickoff commit
7e5453a).
Where the contract leaves a behaviour open, this document says defined by
implementation. Do not build client logic that depends on those behaviours; handle every
allowed case instead.
- Conventions
- Money encoding
- Authentication and CSRF
- Errors
- Health and status
- REST endpoints
- Trading
- WebSocket
Conventions
-
Same origin. The browser talks only to the backend, never to a venue. In development
the Vite dev server at
http://localhost:5173serves the app and proxies/apiand/wsto the backend on127.0.0.1:8080. In production the Go binary serves both. The backend sends no CORS headers. - Field names are snake_case in JSON.
-
Timestamps are RFC 3339 strings in UTC, e.g.
"2026-09-30T03:12:40Z". -
IDs are namespaced strings: a venue prefix, a colon, then the venue’s id
(
Idschema, pattern^[a-z]+:[A-Za-z0-9_.-]+$, at most 120 characters). For Polymarket (core/market.go):Event, league, team and tag ids use the same prefix; their venue part is defined by implementation. Treat every id as an opaque string. - Money, prices and sizes are decimal strings. See the next section.
Money encoding
Every price, size, amount, volume and tick size in the API is aDecimal: a JSON string
matching ^\d+(\.\d{1,6})?$, at most 20 characters.
In Go, a
Decimal is parsed with core.ParseMicros into core.Micros, an int64 count
of millionths. All arithmetic on values that are sent or stored happens on Micros. In
TypeScript, values stay strings from the API to the screen and are formatted with
lib/format. The browser may compute display-only previews (share estimates) with a
decimal library.
A few numeric-looking fields are not Decimal:
Market.lineis a signed decimal string for a spread or total, e.g."-3.5". It is a label, not money.Game.home_scoreandGame.away_scoreare free-form strings, because some sports report sets or maps ("7-6(7-2), 0-0").- Counts (
max_open_orders,price_band_ticks,seconds_delay,market_count,positions_count,open_orders,resting_orders) and the WebSocketseqare JSON integers.
SignedDecimal for values that can be negative: a Decimal with an
optional leading - (^-?\d+(\.\d{1,6})?$, at most 21 characters), e.g. "-12.5". It is
used for realized_pnl, unrealized_pnl, the P&L series and Activity.amount, where the
sign gives the direction of the change for the account. The other rules above apply.
Why there are no floats
- Binary floats cannot hold most decimal prices.
0.1,0.47and0.53have no exactfloat64or JSnumbervalue, so0.1 + 0.2is0.30000000000000004. A price that drifts by one unit in the last place no longer matches its book level or tick. - A click sends exactly the price shown. Clicking a price sends a GTC limit order at
that price (
CLAUDE.md). If the displayed string came from a float it could differ from the level the backend holds. - Signing must match byte for byte. Order amounts are derived from price and size with integer floor rounding and checked against golden vectors from the official SDK. Float rounding can differ from an integer floor at boundaries.
- Range.
Microsis anint64. A JSnumberis exact only up to 2^53, about 9 × 10^15 micros (9 billion units); a string never loses precision. - Tick checks must be exact.
engine/riskchecks that a price is a whole multiple of the tick size. That test is exact on integers and unreliable on floats.
Authentication and CSRF
The UI has one user and one password. A session is a server-side row keyed by a random token that the browser holds in a cookie.-
POST /api/auth/loginwith{"password": "..."}. On success the backend sets thesesame_sessioncookie (HttpOnly,SameSite=Strict,Path=/, plusSecurebehind TLS) and returns aSession: -
GET /api/auth/sessionreturns the same shape. It is public, so the app can call it on load to learn whether it is logged in and to get the CSRF token again after a reload. When logged out it returns{"authenticated": false}. -
Every operation is authenticated unless the spec marks it
security: []. The public operations areGET /health,POST /auth/loginandGET /auth/session. A request without a valid session gets 401unauthorized. -
Every authenticated request that is not
GETorHEADmust also send theX-CSRF-Tokenheader with the session’scsrf_token. A missing or wrong token gets 403csrf_invalid. The generated client adds the header in middleware. -
POST /api/auth/logoutis authenticated and needs the CSRF header. It deletes the session and clears the cookie.
rate_limited when the limit is hit.
POST /api/auth/logout-all (session and CSRF) deletes every session, closes every socket,
and revokes every known-device cookie.
Known-device cookie (Phase 6). A successful login also sets sesame_device
(__Host-sesame_device with COOKIE_SECURE), valid 180 days and renewed at each login. It
authenticates nothing: a request carrying it still needs a session. Failed logins are
limited per client; a browser with a valid known-device cookie is limited by itself and is
never refused by the login breaker, which refuses browsers without one after failed logins
from many addresses and sends a login_breaker notification. The cookie is signed with the
stored device generation; logout-all and a restore increase it, so every earlier cookie
counts as an unknown device again.
Phase 1 adds the security controls adopted for this phase (security findings F11, F14,
F24): a Host allowlist, an Origin check on every mutating request including login,
token and CSRF rotation at login, and idle and absolute session timeouts. The exact
values are defined by implementation; the rules are in
review-checklist.md.
Errors
Every error response has theError body:
code is stable and machine-readable; branch on it and on the HTTP status. message is
a fixed, factual text for people; never parse it. Venue response bodies and internal
details never appear in either field. They reach only the logs.
Codes defined by the backend at Phase 1 kickoff (httpapi/server.go):
The codes for Phase 1 resource errors, such as an unknown market id (404), are defined by
implementation. A client that meets an unknown code must fall back to the HTTP status.
The spec’s
Error.code description lists the full set of codes known today.
Phase 2 adds one code for the account endpoints:
A client that gets
credentials_unavailable shows the credential state from
AccountSummary.credentials (see Credentials) rather than a generic
error. The account topic reports when the state changes, so the client can refetch
then.
Phase 3 adds two codes, named in the trading operations’ descriptions but not yet in the
Error.code list:
An order the risk engine or the venue refuses is not an HTTP error: it is a 200 with
status: "rejected" and a reject (see Trading).
The spec sets input limits (security finding F23): q up to 200 characters, ids up to
120, cursor up to 500, limit ranges per endpoint, password up to 1024, and a
Decimal up to 20 characters. How each limit is enforced is defined by implementation; a
request that breaks one is rejected, not truncated.
Health and status
Two endpoints report on the backend. The split is ADR 0008 (security finding F20).
When
status is degraded is defined by implementation. At kickoff the backend reports
degraded when the latest geoblock check failed.
Status fields:
version: the backend build version.trading_enabled: true only whenLIVE_TRADINGis exactlytrueand the latest geoblock check succeeded and reported not blocked. The SAFE/ARMED switch is separate state, checked per order.geoblock:blocked,checked_at, andcountry,regionanderrorwhen known.blockedis true before the first successful check and whenever the latest check failed (fail closed).feeds: oneFeedStatusper venue feed:feed(e.g.pm.market,pm.sports),connected,last_message_at, anderrorwhen the feed is down.
Status object is available live on the status WebSocket topic.
sesame healthcheck (Phase 6) calls GET /api/health on the loopback address of
HTTP_ADDR within 3 s; the container uses it as its health probe.
Serving the web UI
In development Vite serves the UI on:5173 and proxies /api and /ws to the backend.
From Phase 6 the production binary embeds the built bundle (backend/internal/webui), and
one origin serves everything:
Only
GET and HEAD are answered. Files whose name starts with a dot are not served.
Content types come from a fixed table, not the operating system’s. A binary built without a
bundle answers every non-API path with 404. TLS terminates at Caddy in front of the backend;
TRUSTED_PROXY names the proxy whose X-Forwarded-For header is believed.
REST endpoints
All paths are under/api. “CSRF” means the request also needs the X-CSRF-Token
header. Every authenticated endpoint can also return 401.
system
auth
settings
From Phase 3,
Settings.risk is read-only: PUT /settings ignores it, and risk limits
change only through PUT /settings/risk (security finding F7). See
Risk settings.
markets
searchserves the command palette from the local search index (catalog).qis required, 1–200 characters;limitis 1–50, default 20. EachSearchHithas akind(event,market,teamorleague), anid, atitle, and optionallysubtitle,image,live,pinned,event_idandgame_id. Ranking is defined by implementation, following PLAN §4.2 (pinned, then held, then live, then volume, inside text-match tiers).eventspages with an opaque keysetcursor. Pass the previous page’snext_cursor; it is absent on the last page.limitis 1–100, default 50.historyreturns the price series of one outcome token (token_id) overinterval:1h,6h,1d,1w,1mormax. Each point is{"t": <time>, "p": <Decimal>}.
sports
windowislive(default),todayorupcoming. Repeatleagueto filter by up to 50 league ids.- The board is grouped by league. Each
GameCardhas theGameand its main markets in the order moneyline, main spread, main total, when present. - The board gives the game state at the time of the request. Later changes arrive on
game:<game_id>; prices arrive only onbook:<token_id>.
watchlist
account
Added in Phase 2. All are read-onlyGETs; placing and cancelling orders are in
Trading.
accountalways answers 200, with or without credentials.AccountSummaryhaswallet_address(empty when not configured),credentials,positions_countandopen_orders, and optionallycashwithcash_updated_at,liquidation_value,cost_basis,unrealized_pnlandvaluation_stale.cashis absent while it cannot be read. Whatopen_ordersholds while credentials are notreadyis defined by implementation. Live updates arrive on theaccounttopic.portfolio/positions:statusisopen(default),redeemableorclosed. Aredeemableposition has resolved and is claimed on polymarket.com; the app never redeems. EachPositioncarries the venue’ssize,avg_price,cost_basisandrealized_pnl, and, for open positions, the valuation fromengine/portfolio:best_bid,liquidation_value,liquidation_complete(false when the visible bids cannot absorb the whole position),unrealized_pnl,valuation_staleandresting_orders. Open positions also stream on thepositionstopic.portfolio/pnl:intervalis1d,1w,1morall. Points are{"t": <time>, "pnl": <SignedDecimal>}, the cumulative P&L from the Data API.orderslists open orders, optionally for onemarket_id. It needs venue credentials. Updates stream on theorderstopic.activitypages the account history (trades, redeems, merges, splits, rewards, conversions and transfers made on polymarket.com) with an opaquecursor; pass the previous page’snext_cursor.limitis 1–100, default 50. It is history only; live fills come from thefillstopic.
Credentials
AccountSummary.credentials is a CredentialStatus:
Reason codes:
no_signer, derive_failed, auth_rejected, geoblocked and network.
Phase 3 adds wallet_mismatch: the session-signers check at credential start found that
the Session Key is not a signer of the configured wallet (security P2-03). The spec’s
CredentialStatus.reason description does not list it yet.
Their meanings and what the owner checks for each are in the
Session Key setup runbook. The venue’s
own error text never reaches the browser. The credential secrets themselves never appear
in any response or frame (ADR 0009).
No prices in REST
No REST response carries a current market price from its own source. Search hits, event listings, event and market detail, the board and the watchlist hold metadata only. Every displayed bid, ask and last trade comes from abook:<token_id> topic
(ADR 0007). volume, liquidity,
tick_size and min_size are market metadata, not prices. Price history is a separate
signal with its own source (PLAN §3.7) and is used only for charts.
Phase 2 position valuation (best_bid, liquidation_value, unrealized_pnl) is computed
by engine/portfolio from the same local books, not read from another price source. The
price of an order, a fill or an activity entry is what the account traded or asked for,
not a market price.
Trading
Added in Phase 3 (tagtrading in the spec). Every trading endpoint needs a session, and
every POST needs the CSRF header. The Go types behind them are in
backend/internal/core/trading.go. The user-facing behaviour is in the
user guide; the order path is
ADR 0011.
The 403s are
csrf_invalid or origin_not_allowed. Which 503 codes the trading
endpoints return (for example credentials_unavailable) is defined by implementation.
Placing an order
PlaceOrderRequest:
Business outcomes are 200. A 4xx means the request itself is malformed or not
authorised. Whether the order was placed is in
PlaceOrderResponse:
- Idempotency. A repeated
client_order_idreturns the first result and sends nothing. A client that did not receive a response may resend the same request with the sameclient_order_idsafely; it must never mint a new id for the same intent. unknownmeans the backend did not get the venue’s answer in time. It never re-sends the order; it reconciles from the venue’s open orders and trades, and the outcome arrives on theordersandfillstopics. From Phase 4 the reconciled result also arrives on theplacementstopic as aPlaceOrderResponsewith the sameclient_order_id. The client shows the order as pending until then.- Reject codes are the
RejectCodeconstants incore/trading.go: risk codes such asnot_armed,price_bandandmax_order_notional, and venue codes prefixedvenue_. They are listed under Reject codes. A client that meets an unknown code showsmessage. - The browser sends the stake, not the size. A one-click order carries
notional(the stake in pUSD) and the backend sets size = notional ÷ price, rounded down to 0.01 shares, records that size in the audit log and checks it against the tick, the minimum size and the limits before signing.sizeis sent instead only where the user chose a share count (exactly one of the two is set). Exit and Hedge send neither: the server sizes them.
Reject codes
Reject.code is one of the RejectCode constants in core/trading.go (plus
venue_not_found from engine/orders). Risk rejects that break a limit also carry
limit and value (and reference_price for price_band); the browser then shows the
figures, e.g. max order $100.00 (order $250.00). The browser’s fixed text per code is in
frontend/src/lib/trading/reject-messages.ts; what each means for the user is in the user
guide’s reject messages and
combo refusals.
retry_after_ms is set for venue_post_only, venue_restarting, venue_rate_limited and
max_quotes_per_minute. A price_band reject on a reduce-only sell (Exit, Join) carries
the exit slippage as limit, since those orders are held to the best bid minus the slippage
instead of the band.
Cancelling
CancelOrdersRequest.order_idsholds 1 to 1000 venue order ids.CancelMarketRequesthas amarket_idand an optionaltoken_idto cancel one outcome only.cancel-allhas no body and cancels every open order of the account on the venue.- Cancels work in SAFE, with
LIVE_TRADINGoff and while geoblocked; the risk engine never blocks them.
CancelResponse has three lists:
Replacing an order
POST /orders/{order_id}/replace with ReplaceOrderRequest (client_order_id for the
new order, the new price, and optionally a new size). The backend cancels the order
and, once the cancel is confirmed, places the new one through the risk engine.
ReplaceOrderResponse has the cancel result and, when a placement was attempted, the
place result. If the cancel fails there is no place. 404 means the order id is not
one of the account’s open orders.
Exiting a position
POST /positions/exit with ExitPositionRequest: client_order_id, token_id and
mode:
walksells the whole position at the lowest bid level that fills it, no more thanexit_slippage_ticksbelow the best bid.joinrests a post-only sell for the whole position at the best ask.
PlaceOrderResponse. Position.exit_price and Position.exit_complete (false when
the bids within exit_slippage_ticks cannot absorb the whole position) come from
engine/portfolio.
Trading state and the switch
TradingState answers “can an order be placed now, and if not, why”:
- ARMED is held in memory only. Every start is SAFE. The backend drops to SAFE on idle timeout, logout, a geoblock change, loss of credentials and foreign activity.
POST /trading/armreturns 409not_armablewhilereasonsholds anything besidesnot_armed.POST /trading/safealways succeeds.- A trading action extends
armed_until; which requests count is defined by implementation.
Risk settings
RiskSettings has limits and ceilings, both RiskLimits:
ceilings come from the environment (RISK_CEILING_*) and cannot be changed through the
API. PUT /settings/risk replaces the limits:
- No limit may exceed its ceiling (400).
- Raising any limit, widening the band or the slippage, or turning off a safety setting
needs
password(the UI password) in the same request; without it the answer is 403step_up_required. Lowering needs no password. - A limit of 0 rejects every order it covers.
Alerts and notifications
Added in Phase 4 (tagsalerts and notifications in the spec). The user-facing behaviour
is in the user guide and the
Telegram runbook; the actors are in the
architecture overview.
- Alerts.
CreateAlertRequesthasalert_kind(price,spread,score,game_start,game_end),token_idfor price and spread alerts orgame_idfor game alerts,cross_direction(abovewatches the best bid,belowthe best ask) andpriceon the market’s tick, and optionalrepeatandnote(200 characters). An update can pause or resume an alert and change its price, direction, repeat and note. - Notifications. Each
Notificationhas anid, anotification_kind, aseverity(info,warning,critical), atitleandbody, and when they apply amarket_id,token_id,game_id,alert_idorclient_order_id. Theclient_order_idof an order notification is the placement’s, so the tab that clicked can skip toasts it already showed.POST /notifications/readmarks every notification up to and includingup_to. - Settings.
NotificationSettingsholds one rule per kind (in_app,desktop,sound,telegram), thesound_volume, and a read-onlytelegram_configured.in_appandtelegramare always on forforeign_activityandcredentials; a rule that turns them off is refused with 400. The browser applies the in-app, desktop and sound rules to what arrives on thenotificationstopic; the server applies the Telegram rule. - Test.
POST /notifications/testsends one notification of kindtestto every channel, whatever the rules. It reaches the browser channels even when Telegram fails (502telegram_error); without Telegram configured it succeeds without it. - Telegram is outbound only and has no endpoint here: the server sends, and never
receives updates.
TELEGRAM_BOT_TOKENandTELEGRAM_CHAT_IDare set together or not at all; without themtelegram_configuredis false and nothing goes to Telegram.
Combos
Added in Phase 8 (tagcombos in the spec). The Go types behind them are in
backend/internal/core/combos.go. The user-facing behaviour is in the
user guide; the design is in
ADR 0014, ADR 0015
and ADR 0016, and the flow in the
architecture overview.
- Quote. A buy sends
client_order_id,side: "buy",leg_token_ids(2 to 50pm:outcome tokens) andnotional(the stake, fees included). A sell (Exit) sendstoken_id(thepmc:combo position) andsize.statusisquoted,no_quoteorrejected;rfqcarries the quote withexpires_in_ms, computed by the server when it sends the message. The browser counts down from that value from the moment the message arrives, never from its own clock againstexpires_at. A quote request works in Safe and is never repeated. - Accept. The body is ids only:
client_order_id,rfq_id,quote_id.statusisaccepted,rejectedorunknown. An unknown Accept is settled by status reads and never re-sent; its final state arrives on thecombo_rfqstopic with acombo_filledorcombo_failednotification. 404not_foundis anrfq_idthat is not one of this app’s recent quote requests. - Business outcomes are 200, as for orders. The combo reject codes are listed in the Reject codes section.
- 503
combos_unavailablemeans no Combos venue is configured: the quote, accept and RFQ endpoints without a Session Key, the positions and activity endpoints without a wallet address. As a reject code or acombos_reasonsentry,combos_unavailablemeans the credentials lack the combos scope. GET /tradingcarriescombos_can_tradeandcombos_reasons;GET /accountcarriescredentials.scopes(trading,combos) andcombo_cost_basis.
WebSocket
Connecting
- One socket per browser tab, at
GET /wson the same origin as the app (ws://in development through the Vite proxy,wss://behind TLS)./wsis outside/apiand is not an OpenAPI path; its messages are theWs*schemas in the spec. - Auth: the
sesame_sessioncookie is checked at the upgrade exactly as for REST, including timeouts. No token goes in the URL or the first frame. An upgrade without a valid session is refused before the socket opens. - Origin: the upgrade must carry an
Originheader equal to the app’s configured public origin (plus the Vite dev origin in development). A missing or foreignOriginis refused with 403 before the upgrade. This stops another site from opening the socket with the user’s cookie. - Sockets are receive-only for data. The client can only subscribe, unsubscribe and ping. Commands go over REST.
- The security checklist requires logout to close the session’s sockets. Whether an expired session closes an open socket immediately is defined by implementation.
GET /api/auth/session
to tell an expired session from a network problem.
Client messages
WsClientMessage: op is required; topics and id are optional.
Server messages
WsServerMessage: type is required; the other fields depend on it.
Whether one
sub with several topics produces one ack or one per topic, and whether the
ack comes before or after the snapshots, are defined by implementation. error codes for
the WebSocket (unknown topic, too many topics, bad message) are defined by implementation.
Topics
<token_id> and <game_id> are full namespaced ids, so a topic has two colons:
book:pm:<tokenId>, game:pm:<gameId>. The other topics take no parameter: there is
one account.
Applying messages:
book: a snapshot replaces the book. A delta lists changed levels as[price, size]pairs; each sets the size at that price, and size"0"removes the level. A delta can also carrytick_size,last_trade,staleandupdated_at; apply each field present and keep the rest. Bids are sorted high to low and asks low to high in a snapshot; after applying a delta the client keeps that order.game: replace the storedGamewithdata.feeds: a snapshot replaces the list; a delta replaces the entry with the samefeedname, or adds it.status: replace the storedStatuswithdata.account: replace the storedAccountSummarywithdata.positions: a snapshot replaces the list of open positions. A delta replaces the position with the sametoken_id, or adds it; a delta withsize"0"removes it. Valuation changes are coalesced to at most one delta per second per position.orders: a snapshot replaces the list of open orders. A delta replaces the order with the sameid, or adds it. A delta whosestatusis terminal (matched,cancelledorunmatched) removes the order from the open list;liveanddelayedkeep it. Adelayedorder cannot be cancelled untildelay_ends_at, when the venue reports it.fills: a snapshot replaces the list with today’s fills. A delta adds a fill, or replaces the fill with the sameid: a fill’sstatusmoves frommatchedtominedtoconfirmed, and can pass throughretryingor end infailed(the match was reverted). Whether every status step is sent, and where “today” starts, are defined by implementation.trading: replace the storedTradingStatewithdata. The UI drives the SAFE/ARMED switch, the ARMED marker and the trade buttons’ enabled state from it, and still treats the server’s reply to each order as the answer. How often counters such asorders_last_minuteproduce a delta is defined by implementation.ordersin Phase 3: anOrderplaced by this app carries itsclient_order_id, so the client can match a pending intent to its order.cancel_pending: truemarks an order whose cancel is queued behind a sports delay window.
orders and fills topics have no source. Whether
subscribing then returns an error with credentials_unavailable or an empty snapshot is
defined by implementation; the account topic always carries the credential state.
After the user feed reconnects. The venue’s user feed does not replay missed events.
After a reconnect the backend re-reads open orders over REST (PLAN §3.7) and sends the
result. Whether that arrives as a new orders snapshot or as deltas is defined by
implementation; the rules above handle both. Positions and cash are refetched after every
fill and every 60 s, so they converge without a reload.
Examples, built from the schemas (the book uses the token and prices of the Phase 0 NHL
capture):
Snapshot, deltas and seq
- The server answers a subscription with a
snapshotof the topic, then sendsdeltamessages as the topic changes. - Every
snapshotanddeltacarriesseq. It increases by one per message per topic. The client stores theseqof each snapshot as the topic’s base and expects each following message on that topic to be exactly one higher. - A gap means the client’s state for that topic is wrong. On a
seqthat is not the previous value plus one, the client drops its state for the topic, shows it as stale, and resubscribes that topic:unsubthensub. It ignores deltas for the topic until the new snapshot arrives.unsubthensubworks whether or not a repeatedsubon an already-subscribed topic sends a fresh snapshot, which is defined by implementation. - Whether
seqstarts at 1 or continues after a resubscribe is defined by implementation. Always take the new snapshot’sseqas the base. - A client may receive a
snapshoton a topic it is already following, for example after the venue feed resyncs. It replaces the topic’s state with it, like the first snapshot. - Deltas for a topic that has no snapshot yet are ignored.
- Updates are coalesced to about 10 per second per topic. A coalesced book delta holds the latest size of every level that changed in the window, so the rules above still apply. The exact rate is defined by implementation.
Ping and pong
- The client sends
{"op": "ping", "id": ...}; the server answers{"type": "pong", "id": ...}with the sameid. Browsers cannot see WebSocket protocol pings, so this is how the client measures latency and detects a dead socket. The interval and the timeout after which the client treats the socket as dead and reconnects are defined by implementation. - The server also sends WebSocket protocol-level pings, which browsers answer automatically, and closes a socket that stops answering. The security checklist sets every 30 s with a drop after 2 missed pongs; the final values are defined by implementation.
Limits
A book topic for a Polymarket token is about 85 characters, so a
sub for 500 book
topics fits in a 64 KiB message. The security checklist also proposes at most 8 sockets
per session and 20 sub/unsub messages per second; these are defined by
implementation. Each sub/unsub costs 1 plus 1 per 50 topics from a budget of 20 a
second per socket; above it the hub delays reading the next message instead of closing the
socket. The client batches subscription changes every 150 ms, sends at most 100 topics a
message and keeps to half that budget. Only 20 malformed messages in a minute close the
socket with 1008, and a send queue over 8 MiB closes it as a slow client (reading pauses
above 4 MiB). Book holds are kept per connection and all released when it closes.
Slow clients
Each socket has a bounded send queue. If the client reads too slowly and the queue fills, the server closes the socket. It does not drop single messages, because a dropped delta would leave the client’s book wrong with no way to notice before the next gap. The queue size is defined by implementation.Reconnecting
When the socket closes for any reason:- Mark every subscribed topic stale in the UI; keep showing the last values greyed out.
- Reconnect with a backoff. The schedule and jitter are defined by implementation.
- Resubscribe every topic the UI still needs, in
submessages of at most 500 topics. - Replace each topic’s state with its new snapshot and take its
seqas the base. Nothing from the old socket carries over.
Stale books
A book is stale when the backend cannot vouch for it: the market feed is disconnected, or the book for that token is being resynced.BookSnapshot.staleis true while the market feed is disconnected or resyncing that token. ABookDeltathat carries onlystalechanges the flag and leaves the levels as they are.- When the feed recovers, the backend resubscribes the token at the venue for a fresh
book (never REST
/book, PLAN §3.7) and sends the new state withstale: false. Whether that state arrives as a newsnapshotor as deltas is defined by implementation; the rules above handle both. - While a book is stale the UI greys its prices and disables clicking them. It never fills the gap from another source.
- The
feedstopic reports each feed’sconnectedflag andlast_message_at, which drive the feed status indicator. A game has nostalefield; whilepm.sportsis disconnected, the UI shows game state from that feed as stale. - The lag threshold after which a connected but silent feed counts as stale, and whether the backend or the client applies it, are defined by implementation.