> ## Documentation Index
> Fetch the complete documentation index at: https://docs.sesameterminal.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Browser and backend protocol

# 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`](../../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](#conventions)
* [Money encoding](#money-encoding)
* [Authentication and CSRF](#authentication-and-csrf)
* [Errors](#errors)
* [Health and status](#health-and-status)
* [REST endpoints](#rest-endpoints)
* [Trading](#trading)
* [WebSocket](#websocket)

## 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`):

  | Object | Form | Example |
  | - | - | - |
  | Market | `pm:<conditionId>` | `pm:0xfe4a8a13fb0096ad21c70e189f9c69c4644d8022231ddb3017d527441db65547` |
  | Outcome token | `pm:<tokenId>` | `pm:99853173104990688338093880260846336068357323093533009368456412293249893123771` |
  | Game | `pm:<gameId>` | `pm:30025460` |

  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.

| Rule | Detail |
| - | - |
| Non-negative | No sign. `"0"` is valid. |
| Up to 6 decimal places | Polymarket's base unit is one millionth (`core.MicrosPerUnit`). |
| No trailing zeros from the backend | The backend writes `core.Micros.String()`: `"25.5"`, `"0.47"`, `"6385"`. The client must still accept `"25.50"`. |
| No exponent, no leading `+`, no spaces | `"1e3"`, `"+1"` and `" 1"` are rejected. |

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`:

   ```json theme={null}
   { "authenticated": true, "csrf_token": "<token>", "expires_at": "2026-09-30T15:00:00Z" }
   ```

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](../security/review-checklist.md).

## Errors

Every error response has the `Error` body:

```json theme={null}
{ "code": "csrf_invalid", "message": "missing or wrong X-CSRF-Token header" }
```

`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`):

| Code | Status | Meaning |
| - | - | - |
| `unauthorized` | 401 | No valid session, or a wrong password at login |
| `csrf_invalid` | 403 | Missing or wrong `X-CSRF-Token` on a mutating request |
| `rate_limited` | 429 | Too many login attempts |
| `validation_failed` | 400 | A parameter or body failed binding or the spec's limits |
| `not_found` | 404 | No such route |
| `method_not_allowed` | 405 | The route exists but not for this method |
| `internal` | 500 | Anything else; details are in the log |

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:

| Code | Status | Meaning |
| - | - | - |
| `credentials_unavailable` | 503 | The request needs venue API credentials, and the credential state is not `ready` |

A client that gets `credentials_unavailable` shows the credential state from
`AccountSummary.credentials` (see [Credentials](#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:

| Code | Status | Meaning |
| - | - | - |
| `not_armable` | 409 | `POST /trading/arm` while trading cannot be enabled; `GET /trading` `reasons` says why |
| `step_up_required` | 403 | `PUT /settings/risk` raises a limit without the UI password |

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](#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](../adr/0008-public-health-authenticated-status.md) (security finding F20).

| | `GET /api/health` | `GET /api/status` |
| - | - | - |
| Auth | Public | Session cookie |
| Body | `Health`: `{"status": "ok" \| "degraded"}` | `Status`: `version`, `trading_enabled`, `geoblock`, `feeds` |
| Use | Liveness checks by a container runtime, reverse proxy or uptime monitor | The UI's status pill and feed indicator; debugging |
| HTTP status | Always 200 while the process serves requests | 200, or 401 without a session |

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:

| Path | Served |
| - | - |
| `/api/*`, `/ws` | The API and the WebSocket, as in this document |
| `/assets/*` | Bundle files whose names carry a content hash: `Cache-Control: public, max-age=31536000, immutable` |
| Other files in the bundle (`sw.js`, the manifest, icons, fonts) | `Cache-Control: no-cache`, revalidated by `ETag` |
| `/`, and any other path with no file extension outside `/assets/` | `index.html`, for client-side routes |
| A missing asset or any other name with an extension | 404, never `index.html` |

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

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `GET /health` | Public | No | `Health` | |
| `GET /status` | Session | No | `Status` | |

### auth

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `POST /auth/login` | Public | No | `Session`, sets the cookie | 401, 429 |
| `POST /auth/logout` | Session | Yes | 204, clears the cookie | |
| `GET /auth/session` | Public | No | `Session` | |
| `POST /auth/logout-all` | Session | Yes | 204, clears the cookie | |

### settings

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `GET /settings` | Session | No | `Settings` | |
| `PUT /settings` | Session | Yes | `Settings` as saved | 400, 403 |
| `GET /settings/risk` | Session | No | `RiskSettings`: `limits` and their `ceilings` | |
| `PUT /settings/risk` | Session | Yes | `RiskSettings` as saved | 400, 403 `step_up_required` |

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](#risk-settings).

### markets

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `GET /search?q=&limit=` | Session | No | `SearchResults` | 400 |
| `GET /events?tag=&live=&sports=&cursor=&limit=` | Session | No | `EventPage` | 400 |
| `GET /events/{event_id}` | Session | No | `Event` with its markets | 404 |
| `GET /markets/{market_id}` | Session | No | `Market` | 404 |
| `GET /markets/{market_id}/history?token_id=&interval=` | Session | No | `PriceHistory` | 400, 404 |

* `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

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `GET /sports/leagues` | Session | No | `League[]` | |
| `GET /sports/board?window=&league=` | Session | No | `SportsBoard` | 400 |

* `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

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `GET /watchlist` | Session | No | `WatchItem[]`, most recently pinned first | |
| `PUT /watchlist/{market_id}` | Session | Yes | 204; pinning twice is a no-op | 403, 404 |
| `DELETE /watchlist/{market_id}` | Session | Yes | 204; unpinning twice is a no-op | 403 |

### account

Added in Phase 2. All are read-only `GET`s; placing and cancelling orders are in
[Trading](#trading).

| Method and path | Auth | CSRF | Returns | Other errors |
| - | - | - | - | - |
| `GET /account` | Session | No | `AccountSummary` | |
| `GET /portfolio/positions?status=` | Session | No | `PositionList` | 503 |
| `GET /portfolio/pnl?interval=` | Session | No | `PnlSeries` | 400, 503 |
| `GET /orders?market_id=` | Session | No | `OrderList` of open orders | 503 `credentials_unavailable` |
| `GET /activity?cursor=&limit=` | Session | No | `ActivityPage`, newest first | 400, 503 |

* **`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`:

| Field | Meaning |
| - | - |
| `state` | `none`: no signer key and no API credentials configured. `pending`: deriving. `ready`: configured or derived, and the last use succeeded. `failed`: derivation or the last authenticated call failed |
| `signer_address` | Public address of the configured signing key (the Session Key); empty or absent without one |
| `reason` | A fixed reason code when `state` is `failed`, at most 64 characters |

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](../runbooks/session-key-setup.md#7-troubleshooting). The venue's
own error text never reaches the browser. The credential secrets themselves never appear
in any response or frame ([ADR 0009](../adr/0009-venue-credentials-in-memory.md)).

### 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](../adr/0007-prices-only-from-market-feed.md)). `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](../guides/user/trading.md); the order path is
[ADR 0011](../adr/0011-audit-first-order-path.md).

| Method and path | Body | Returns | Other errors |
| - | - | - | - |
| `POST /orders/place` | `PlaceOrderRequest` | `PlaceOrderResponse` | 400, 403, 503 |
| `POST /orders/cancel` | `CancelOrdersRequest` | `CancelResponse` | 400, 403, 503 |
| `POST /orders/cancel-market` | `CancelMarketRequest` | `CancelResponse` | 400, 403, 503 |
| `POST /orders/cancel-all` | none | `CancelResponse` | 403, 503 |
| `POST /orders/{order_id}/replace` | `ReplaceOrderRequest` | `ReplaceOrderResponse` | 400, 403, 404, 503 |
| `POST /positions/exit` | `ExitPositionRequest` | `PlaceOrderResponse` | 400, 403, 503 |
| `GET /trading` | | `TradingState` | |
| `POST /trading/arm` | none | `TradingState` | 403, 409 `not_armable` |
| `POST /trading/safe` | none | `TradingState` | 403 |

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`:

| Field | Required | Rules |
| - | - | - |
| `client_order_id` | Yes | A UUIDv7 (`ClientOrderId` pattern), minted by the browser once per user intent. It is the idempotency key |
| `token_id` | Yes | The outcome token, `pm:<tokenId>` |
| `side` | Yes | `buy` or `sell` |
| `price` | Yes | `Decimal`; must be on the market's tick grid |
| `size` | Yes | `Decimal`, in shares |
| `order_type` | No | `GTC` (default), `GTD`, `FOK` or `FAK`. A price click always sends `GTC` |
| `expires_at` | GTD only | At least 3 minutes ahead |
| `post_only` | No | `GTC` and `GTD` only |

**Business outcomes are 200.** A 4xx means the request itself is malformed or not
authorised. Whether the order was placed is in `PlaceOrderResponse`:

| Field | Meaning |
| - | - |
| `client_order_id` | Echoed |
| `status` | `accepted`, `rejected` or `unknown` |
| `order_id` | The venue order id, when accepted |
| `order_status` | When accepted: `live`, `delayed`, `matched` or `unmatched` |
| `reject` | When rejected: `code`, a fixed `message`, and `retry_after_ms` for `venue_post_only`, `venue_restarting` and `venue_rate_limited` |

* **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](#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](../guides/user/order-rules.md#reject-messages) and
[combo refusals](../guides/user/combos.md#why-a-quote-or-accept-was-refused).

| Group | Codes |
| - | - |
| Trading gates (risk) | `trading_disabled`, `geoblocked`, `not_armed`, `no_credentials`, `clock_skew`, `session_key_expired`, `foreign_activity` |
| Market rules (risk) | `market_closed`, `tick_size`, `min_size`, `price_range`, `price_band`, `book_stale` |
| Limits (risk) | `max_order_notional`, `max_position_notional`, `max_daily_notional`, `max_open_orders`, `max_orders_per_minute`, `duplicate_intent` |
| Position (risk) | `no_position`, `invalid` |
| Hedge (risk) | `not_two_outcome`, `hedged`, `no_asks` |
| Venue | `venue_rejected`, `venue_balance`, `venue_post_only`, `venue_cancel_only`, `venue_disabled`, `venue_restarting`, `venue_rate_limited`, `venue_crosses_book`, `venue_not_found` |
| Combos | `combos_unavailable`, `combo_ineligible`, `combo_contradictory_legs`, `max_combo_notional`, `max_combo_exposure`, `max_combo_legs`, `max_quotes_per_minute`, `combo_already_accepted`, `combo_quote_mismatch`, `combo_no_quotes`, `combo_size_too_large`, `combo_no_response`, `combo_quote_expired`, `combo_maker_declined` |

`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:

| Field | Meaning |
| - | - |
| `cancelled` | Order ids the venue cancelled |
| `pending` | Order ids in a sports delay window. The backend queues their cancel and sends it when the window ends; the order carries `cancel_pending: true` meanwhile |
| `not_cancelled` | `{order_id, reason}` for orders that could not be cancelled, for example already matched. `reason` is short text for display |

### 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":

| Field | Meaning |
| - | - |
| `armed` | The SAFE/ARMED switch |
| `can_trade` | True when an order could be placed now: armed and every gate clear |
| `reasons` | Reject codes of the closed gates, e.g. `not_armed`, `trading_disabled`, `geoblocked`, `no_credentials`, `clock_skew`, `foreign_activity`. Empty when `can_trade` is true |
| `armed_until` | The idle deadline after which the backend drops to SAFE. Its value while SAFE (null or absent) is defined by implementation |
| `clock_skew_ms` | Local clock minus the venue's server time |
| `daily_used` | Notional placed today (UTC), a `Decimal` |
| `orders_last_minute` | Orders placed in the last minute |

* 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`:

| Field | Type | Meaning |
| - | - | - |
| `max_order_notional` | `Decimal` | Largest price × size for one order |
| `max_position_notional` | `Decimal` | Largest position value per market |
| `max_daily_notional` | `Decimal` | Total notional placed per UTC day |
| `max_open_orders` | integer ≥ 0 | Most open orders |
| `max_orders_per_minute` | integer ≥ 0 | Most orders placed per minute |
| `duplicate_window_ms` | 0–10000 | Same market, side, price and size within this window is refused |
| `price_band_ticks` | integer ≥ 1 | Most ticks between a price and the current touch |
| `exit_slippage_ticks` | integer ≥ 0 | How far below the best bid an exit may walk |
| `arm_idle_minutes` | 1–240 | Idle time before ARMED drops to SAFE |
| `heartbeat_enabled` | boolean | Polymarket's dead-man switch |
| `cancel_on_shutdown` | boolean | Cancel open orders when the app stops |

`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](../guides/user/alerts-and-notifications.md) and the
[Telegram runbook](../runbooks/telegram.md); the actors are in the
[architecture overview](overview.md#alerts-and-notifications-phase-4).

| Method and path | Body | Returns | Other errors |
| - | - | - | - |
| `GET /alerts` | | `AlertList` | |
| `POST /alerts` | `CreateAlertRequest` | 201 `Alert` | 400, 403, 404 (target not in the catalog), 429 `limit_exceeded` (200 alerts) |
| `PUT /alerts/{alert_id}` | `UpdateAlertRequest` | `Alert` | 400, 403, 404 |
| `DELETE /alerts/{alert_id}` | | 204 (idempotent) | 403 |
| `GET /notifications` | `before`, `limit` (1–200, default 50) | `NotificationPage`, newest first | 400 |
| `POST /notifications/read` | `MarkNotificationsReadRequest` (`up_to`) | 204 | 400, 403 |
| `POST /notifications/test` | none | 204 | 403, 429 `rate_limited` (once per 10 s), 502 `telegram_error` |
| `GET /settings/notifications` | | `NotificationSettings` | |
| `PUT /settings/notifications` | `NotificationSettings` | `NotificationSettings` | 400, 403 |

* **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](../guides/user/combos.md); the design is in
[ADR 0014](../adr/0014-combos-requester-api.md), [ADR 0015](../adr/0015-combo-accept-path.md)
and [ADR 0016](../adr/0016-combo-ids-and-eligibility.md), and the flow in the
[architecture overview](overview.md#combos-phase-8).

| Method and path | Body | Returns | Other errors |
| - | - | - | - |
| `POST /combos/quote` | `ComboQuoteRequest` | `ComboQuoteResponse` | 400, 403, 503 |
| `POST /combos/accept` | `ComboAcceptRequest` | `ComboAcceptResponse` | 400, 403, 404, 503 |
| `GET /combos/rfqs` | | `ComboRfqList` (this app's quote requests of the last 10 min) | 503 |
| `GET /portfolio/combos` | | `ComboPositionList` (open, or resolved with `status`) | 400, 503 |
| `GET /activity/combos` | | `ComboActivityPage` (`cursor`, `limit`) | 400, 503 |

* **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](#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.

| Field | Type | Rules |
| - | - | - |
| `op` | `"sub"`, `"unsub"` or `"ping"` | Required |
| `topics` | string array | Up to 500 items, each up to 140 characters. Used by `sub` and `unsub` |
| `id` | string | Up to 64 characters. Echoed in the matching `ack`, `error` or `pong` |

```json theme={null}
{ "op": "sub", "id": "c1", "topics": [
  "book:pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
  "game:pm:30025460",
  "feeds",
  "status"
] }
```

```json theme={null}
{ "op": "unsub", "id": "c2", "topics": ["game:pm:30025460"] }
```

```json theme={null}
{ "op": "ping", "id": "p17" }
```

### Server messages

`WsServerMessage`: `type` is required; the other fields depend on it.

| `type` | Fields | Meaning |
| - | - | - |
| `snapshot` | `topic`, `seq`, `data` | The full current state of a topic |
| `delta` | `topic`, `seq`, `data` | A change to a topic since the previous message |
| `ack` | `id`, and `topic` when set | A `sub` or `unsub` was accepted |
| `error` | `error`, and `id` and `topic` when set | A client message or topic was rejected |
| `pong` | `id` | Reply to `ping` |

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

| Topic | `snapshot` data | `delta` data | Produced by |
| - | - | - | - |
| `book:<token_id>` | `BookSnapshot` | `BookDelta` | `engine/books`, from the venue market feed |
| `game:<game_id>` | `Game` | `Game` (the full object) | `engine/games`, from the venue sports feed seeded from Gamma ([ADR 0006](../adr/0006-game-state-seeded-snapshot.md)) |
| `feeds` | `FeedStatusList` | `FeedStatus` (one feed) | The venue feed actors' `FeedStatus` events |
| `status` | `Status` | `Status` (the full object) | The same state as `GET /api/status` |
| `account` | `AccountSummary` | `AccountSummary` (the full object) | Phase 2. Cash and valuation from `engine/portfolio`, the open-order count from `engine/orders`, the credential state from the venue credential actor; which actor assembles the object is defined by implementation |
| `positions` | `PositionList` (open positions) | `Position` (one position) | Phase 2. `engine/portfolio` |
| `orders` | `OrderList` (open orders) | `Order` (one order) | Phase 2. `engine/orders`, from the venue user feed |
| `fills` | `FillList` (today's fills) | `Fill` (one fill) | Phase 2. The venue user feed; the engine actor that relays it is defined by implementation |
| `trading` | `TradingState` | `TradingState` (the full object) | Phase 3. `engine/risk`, on every change of the switch, a gate, the skew or the counters |
| `placements` | `PlacementList` (unknown placements settled by reconciliation in the last 10 min) | `PlaceOrderResponse` (one settled placement) | Phase 4. `engine/orders` |
| `alerts` | `AlertList` | `Alert` (`deleted: true` removes it) | Phase 4. `engine/alerts` |
| `notifications` | `NotificationList` (the latest 50 and the unread count) | `Notification` (one new notification) | Phase 4. `notify`; after read marks or pruning the server sends a new snapshot |
| `combo_rfqs` | `ComboRfqList` (this app's quote requests of the last 10 min) | `ComboRfq` (one per state change) | Phase 8. `engine/orders` |
| `combo_positions` | `ComboPositionList` (open combo positions) | `ComboPosition` (one position) | Phase 8. The combo positions actor in `engine/portfolio`, from the Data API |

`<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):

```json theme={null}
{ "type": "snapshot", "topic": "book:pm:99853173104990688338093880260846336068357323093533009368456412293249893123771", "seq": 1,
  "data": {
    "token_id": "pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
    "bids": [["0.46", "1520"], ["0.45", "3310.5"], ["0.44", "8000"]],
    "asks": [["0.47", "2131"], ["0.48", "905"], ["0.49", "12000"]],
    "tick_size": "0.01",
    "last_trade": { "price": "0.47", "size": "25", "side": "buy", "at": "2026-09-30T03:12:39Z" },
    "stale": false,
    "updated_at": "2026-09-30T03:12:40Z"
  } }
```

```json theme={null}
{ "type": "delta", "topic": "book:pm:99853173104990688338093880260846336068357323093533009368456412293249893123771", "seq": 2,
  "data": {
    "token_id": "pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
    "bids": [["0.46", "0"], ["0.42", "2131"]],
    "updated_at": "2026-09-30T03:12:40.424Z"
  } }
```

```json theme={null}
{ "type": "snapshot", "topic": "game:pm:30025460", "seq": 1,
  "data": {
    "id": "pm:30025460", "league_id": "pm:nhl",
    "home": { "id": "pm:edm", "name": "Edmonton Oilers", "abbreviation": "EDM" },
    "away": { "id": "pm:van", "name": "Vancouver Canucks", "abbreviation": "VAN" },
    "home_score": "2", "away_score": "1", "period": "P2", "clock": "17:36",
    "status": "InProgress", "live": true, "ended": false,
    "start_time": "2026-09-30T02:00:00Z",
    "event_ids": ["pm:nhl-van-edm-2026-09-29"],
    "updated_at": "2026-09-30T03:12:41Z"
  } }
```

(The league, team and event ids in the game example only illustrate the shape; their
venue part is defined by implementation.)

```json theme={null}
{ "type": "snapshot", "topic": "feeds", "seq": 1,
  "data": { "feeds": [
    { "feed": "pm.market", "connected": true, "last_message_at": "2026-09-30T03:12:40Z" },
    { "feed": "pm.sports", "connected": true, "last_message_at": "2026-09-30T03:12:38Z" }
  ] } }
```

```json theme={null}
{ "type": "delta", "topic": "feeds", "seq": 2,
  "data": { "feed": "pm.market", "connected": false, "last_message_at": "2026-09-30T03:14:02Z", "error": "connection closed" } }
```

Account examples (the ids, addresses and amounts only illustrate the shape):

```json theme={null}
{ "type": "snapshot", "topic": "account", "seq": 1,
  "data": {
    "wallet_address": "0x<deposit wallet address>",
    "credentials": { "state": "ready", "signer_address": "0x<session key address>" },
    "cash": "412.5", "cash_updated_at": "2026-09-30T03:12:00Z",
    "liquidation_value": "92", "cost_basis": "90", "unrealized_pnl": "2",
    "valuation_stale": false, "positions_count": 1, "open_orders": 1
  } }
```

```json theme={null}
{ "type": "delta", "topic": "account", "seq": 2,
  "data": {
    "wallet_address": "0x<deposit wallet address>",
    "credentials": { "state": "failed", "signer_address": "0x<session key address>", "reason": "auth_rejected" },
    "positions_count": 1, "open_orders": 0
  } }
```

```json theme={null}
{ "type": "delta", "topic": "positions", "seq": 7,
  "data": {
    "token_id": "pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
    "market_id": "pm:0xfe4a8a13fb0096ad21c70e189f9c69c4644d8022231ddb3017d527441db65547",
    "outcome": "Canucks", "title": "Canucks vs. Oilers",
    "size": "200", "avg_price": "0.45", "cost_basis": "90", "realized_pnl": "0",
    "status": "open", "best_bid": "0.46",
    "liquidation_value": "92", "liquidation_complete": true,
    "unrealized_pnl": "2", "valuation_stale": false, "resting_orders": 1
  } }
```

```json theme={null}
{ "type": "delta", "topic": "orders", "seq": 4,
  "data": {
    "id": "0x<order hash>",
    "market_id": "pm:0xfe4a8a13fb0096ad21c70e189f9c69c4644d8022231ddb3017d527441db65547",
    "token_id": "pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
    "outcome": "Canucks", "side": "sell", "price": "0.55",
    "original_size": "100", "size_matched": "0",
    "status": "cancelled", "order_type": "GTC",
    "created_at": "2026-09-30T03:05:00Z", "updated_at": "2026-09-30T03:12:41Z"
  } }
```

```json theme={null}
{ "type": "delta", "topic": "fills", "seq": 3,
  "data": {
    "id": "<trade id>", "order_id": "0x<order hash>",
    "market_id": "pm:0xfe4a8a13fb0096ad21c70e189f9c69c4644d8022231ddb3017d527441db65547",
    "token_id": "pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
    "outcome": "Canucks", "side": "buy", "price": "0.45", "size": "200", "fee": "0",
    "role": "maker", "status": "confirmed", "at": "2026-09-30T02:58:12Z"
  } }
```

```json theme={null}
{ "type": "delta", "topic": "trading", "seq": 5,
  "data": {
    "armed": true, "can_trade": true, "reasons": [],
    "armed_until": "2026-09-30T03:40:00Z", "clock_skew_ms": 84,
    "daily_used": "12.5", "orders_last_minute": 1
  } }
```

A place request and a risk rejection (the price is 15 ticks from the touch):

```json theme={null}
{ "client_order_id": "0192a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b",
  "token_id": "pm:99853173104990688338093880260846336068357323093533009368456412293249893123771",
  "side": "buy", "price": "0.31", "size": "80.64", "order_type": "GTC" }
```

```json theme={null}
{ "client_order_id": "0192a3b4-c5d6-7e8f-9a0b-1c2d3e4f5a6b", "status": "rejected",
  "reject": { "code": "price_band", "message": "<fixed text>" } }
```

```json theme={null}
{ "type": "ack", "id": "c1" }
```

```json theme={null}
{ "type": "error", "id": "c1", "topic": "book:bad",
  "error": { "code": "<defined by implementation>", "message": "<fixed text>" } }
```

```json theme={null}
{ "type": "pong", "id": "p17" }
```

### 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

| Limit | Value | When exceeded |
| - | - | - |
| Topics per socket | 500 | The subscription is refused with an `error`. Whether a `sub` that only partly fits is applied in part is defined by implementation |
| Topics per client message | 500 (`maxItems`) | The message is rejected |
| Topic length | 140 characters | The topic is rejected |
| Client message size | 64 KiB | The server closes the socket |

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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.