Skip to main content

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

  • Same origin. The browser talks only to the backend, never to a venue. In development the Vite dev server at http://localhost:5173 serves the app and proxies /api and /ws to the backend on 127.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 (Id schema, 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 a Decimal: 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.line is a signed decimal string for a spread or total, e.g. "-3.5". It is a label, not money.
  • Game.home_score and Game.away_score are 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 WebSocket seq are JSON integers.
Phase 2 adds 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.47 and 0.53 have no exact float64 or JS number value, so 0.1 + 0.2 is 0.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. Micros is an int64. A JS number is 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/risk checks 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.
  1. POST /api/auth/login with {"password": "..."}. On success the backend sets the sesame_session cookie (HttpOnly, SameSite=Strict, Path=/, plus Secure behind TLS) and returns a Session:
  2. GET /api/auth/session returns 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}.
  3. Every operation is authenticated unless the spec marks it security: []. The public operations are GET /health, POST /auth/login and GET /auth/session. A request without a valid session gets 401 unauthorized.
  4. Every authenticated request that is not GET or HEAD must also send the X-CSRF-Token header with the session’s csrf_token. A missing or wrong token gets 403 csrf_invalid. The generated client adds the header in middleware.
  5. POST /api/auth/logout is authenticated and needs the CSRF header. It deletes the session and clears the cookie.
Login is rate limited and returns 429 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 the Error 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 when LIVE_TRADING is exactly true and the latest geoblock check succeeded and reported not blocked. The SAFE/ARMED switch is separate state, checked per order.
  • geoblock: blocked, checked_at, and country, region and error when known. blocked is true before the first successful check and whenever the latest check failed (fail closed).
  • feeds: one FeedStatus per venue feed: feed (e.g. pm.market, pm.sports), connected, last_message_at, and error when the feed is down.
The same 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

  • search serves the command palette from the local search index (catalog). q is required, 1–200 characters; limit is 1–50, default 20. Each SearchHit has a kind (event, market, team or league), an id, a title, and optionally subtitle, image, live, pinned, event_id and game_id. Ranking is defined by implementation, following PLAN §4.2 (pinned, then held, then live, then volume, inside text-match tiers).
  • events pages with an opaque keyset cursor. Pass the previous page’s next_cursor; it is absent on the last page. limit is 1–100, default 50.
  • history returns the price series of one outcome token (token_id) over interval: 1h, 6h, 1d, 1w, 1m or max. Each point is {"t": <time>, "p": <Decimal>}.

sports

  • window is live (default), today or upcoming. Repeat league to filter by up to 50 league ids.
  • The board is grouped by league. Each GameCard has the Game and 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 on book:<token_id>.

watchlist

account

Added in Phase 2. All are read-only GETs; placing and cancelling orders are in Trading.
  • account always answers 200, with or without credentials. AccountSummary has wallet_address (empty when not configured), credentials, positions_count and open_orders, and optionally cash with cash_updated_at, liquidation_value, cost_basis, unrealized_pnl and valuation_stale. cash is absent while it cannot be read. What open_orders holds while credentials are not ready is defined by implementation. Live updates arrive on the account topic.
  • portfolio/positions: status is open (default), redeemable or closed. A redeemable position has resolved and is claimed on polymarket.com; the app never redeems. Each Position carries the venue’s size, avg_price, cost_basis and realized_pnl, and, for open positions, the valuation from engine/portfolio: best_bid, liquidation_value, liquidation_complete (false when the visible bids cannot absorb the whole position), unrealized_pnl, valuation_stale and resting_orders. Open positions also stream on the positions topic.
  • portfolio/pnl: interval is 1d, 1w, 1m or all. Points are {"t": <time>, "pnl": <SignedDecimal>}, the cumulative P&L from the Data API.
  • orders lists open orders, optionally for one market_id. It needs venue credentials. Updates stream on the orders topic.
  • activity pages the account history (trades, redeems, merges, splits, rewards, conversions and transfers made on polymarket.com) with an opaque cursor; pass the previous page’s next_cursor. limit is 1–100, default 50. It is history only; live fills come from the fills topic.
Positions, P&L and activity are read from the Data API by wallet address and need no credentials (PLAN Phase 2 decisions). They can still answer 503; the code for that (for example when no wallet address is configured, or the venue is unreachable) is defined by implementation.

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 a book:<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 (tag trading 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_id returns the first result and sends nothing. A client that did not receive a response may resend the same request with the same client_order_id safely; it must never mint a new id for the same intent.
  • unknown means 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 the orders and fills topics. From Phase 4 the reconciled result also arrives on the placements topic as a PlaceOrderResponse with the same client_order_id. The client shows the order as pending until then.
  • Reject codes are the RejectCode constants in core/trading.go: risk codes such as not_armed, price_band and max_order_notional, and venue codes prefixed venue_. They are listed under Reject codes. A client that meets an unknown code shows message.
  • 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. size is 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_ids holds 1 to 1000 venue order ids.
  • CancelMarketRequest has a market_id and an optional token_id to cancel one outcome only.
  • cancel-all has no body and cancels every open order of the account on the venue.
  • Cancels work in SAFE, with LIVE_TRADING off 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:
  • walk sells the whole position at the lowest bid level that fills it, no more than exit_slippage_ticks below the best bid.
  • join rests a post-only sell for the whole position at the best ask.
Both are reduce-only (never more than held) and exempt from the price band. The response is a 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/arm returns 409 not_armable while reasons holds anything besides not_armed. POST /trading/safe always 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 403 step_up_required. Lowering needs no password.
  • A limit of 0 rejects every order it covers.

Alerts and notifications

Added in Phase 4 (tags alerts 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. CreateAlertRequest has alert_kind (price, spread, score, game_start, game_end), token_id for price and spread alerts or game_id for game alerts, cross_direction (above watches the best bid, below the best ask) and price on the market’s tick, and optional repeat and note (200 characters). An update can pause or resume an alert and change its price, direction, repeat and note.
  • Notifications. Each Notification has an id, a notification_kind, a severity (info, warning, critical), a title and body, and when they apply a market_id, token_id, game_id, alert_id or client_order_id. The client_order_id of an order notification is the placement’s, so the tab that clicked can skip toasts it already showed. POST /notifications/read marks every notification up to and including up_to.
  • Settings. NotificationSettings holds one rule per kind (in_app, desktop, sound, telegram), the sound_volume, and a read-only telegram_configured. in_app and telegram are always on for foreign_activity and credentials; a rule that turns them off is refused with 400. The browser applies the in-app, desktop and sound rules to what arrives on the notifications topic; the server applies the Telegram rule.
  • Test. POST /notifications/test sends one notification of kind test to every channel, whatever the rules. It reaches the browser channels even when Telegram fails (502 telegram_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_TOKEN and TELEGRAM_CHAT_ID are set together or not at all; without them telegram_configured is false and nothing goes to Telegram.

Combos

Added in Phase 8 (tag combos 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 50 pm: outcome tokens) and notional (the stake, fees included). A sell (Exit) sends token_id (the pmc: combo position) and size. status is quoted, no_quote or rejected; rfq carries the quote with expires_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 against expires_at. A quote request works in Safe and is never repeated.
  • Accept. The body is ids only: client_order_id, rfq_id, quote_id. status is accepted, rejected or unknown. An unknown Accept is settled by status reads and never re-sent; its final state arrives on the combo_rfqs topic with a combo_filled or combo_failed notification. 404 not_found is an rfq_id that 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_unavailable means 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 a combos_reasons entry, combos_unavailable means the credentials lack the combos scope.
  • GET /trading carries combos_can_trade and combos_reasons; GET /account carries credentials.scopes (trading, combos) and combo_cost_basis.

WebSocket

Connecting

  • One socket per browser tab, at GET /ws on the same origin as the app (ws:// in development through the Vite proxy, wss:// behind TLS). /ws is outside /api and is not an OpenAPI path; its messages are the Ws* schemas in the spec.
  • Auth: the sesame_session cookie 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 Origin header equal to the app’s configured public origin (plus the Vite dev origin in development). A missing or foreign Origin is 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.
A browser cannot read the HTTP status of a refused upgrade; it only sees the socket close. If the socket closes repeatedly before any message arrives, call 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 carry tick_size, last_trade, stale and updated_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 stored Game with data.
  • feeds: a snapshot replaces the list; a delta replaces the entry with the same feed name, or adds it.
  • status: replace the stored Status with data.
  • account: replace the stored AccountSummary with data.
  • positions: a snapshot replaces the list of open positions. A delta replaces the position with the same token_id, or adds it; a delta with size "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 same id, or adds it. A delta whose status is terminal (matched, cancelled or unmatched) removes the order from the open list; live and delayed keep it. A delayed order cannot be cancelled until delay_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 same id: a fill’s status moves from matched to mined to confirmed, and can pass through retrying or end in failed (the match was reverted). Whether every status step is sent, and where “today” starts, are defined by implementation.
  • trading: replace the stored TradingState with data. 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 as orders_last_minute produce a delta is defined by implementation.
  • orders in Phase 3: an Order placed by this app carries its client_order_id, so the client can match a pending intent to its order. cancel_pending: true marks an order whose cancel is queued behind a sports delay window.
Without venue credentials the 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):
(The league, team and event ids in the game example only illustrate the shape; their venue part is defined by implementation.)
Account examples (the ids, addresses and amounts only illustrate the shape):
A place request and a risk rejection (the price is 15 ticks from the touch):

Snapshot, deltas and seq

  • The server answers a subscription with a snapshot of the topic, then sends delta messages as the topic changes.
  • Every snapshot and delta carries seq. It increases by one per message per topic. The client stores the seq of 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 seq that is not the previous value plus one, the client drops its state for the topic, shows it as stale, and resubscribes that topic: unsub then sub. It ignores deltas for the topic until the new snapshot arrives. unsub then sub works whether or not a repeated sub on an already-subscribed topic sends a fresh snapshot, which is defined by implementation.
  • Whether seq starts at 1 or continues after a resubscribe is defined by implementation. Always take the new snapshot’s seq as the base.
  • A client may receive a snapshot on 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 same id. 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:
  1. Mark every subscribed topic stale in the UI; keep showing the last values greyed out.
  2. Reconnect with a backoff. The schedule and jitter are defined by implementation.
  3. Resubscribe every topic the UI still needs, in sub messages of at most 500 topics.
  4. Replace each topic’s state with its new snapshot and take its seq as the base. Nothing from the old socket carries over.
If the reconnect fails because the session has ended, the app goes to the login page.

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.stale is true while the market feed is disconnected or resyncing that token. A BookDelta that carries only stale changes 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 with stale: false. Whether that state arrives as a new snapshot or 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 feeds topic reports each feed’s connected flag and last_message_at, which drive the feed status indicator. A game has no stale field; while pm.sports is 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.