Skip to main content
Prefer WebSocket for order submission. Placing and cancelling orders over the persistent /ws socket is lower-latency (one round trip instead of the REST submit/poll pair) and simpler to build: one connection, one auth handshake, and pushed order_update/fill events instead of polling. See Order Execution over WebSocket.
Receive real-time order status updates, fill notifications, position changes, and balance updates over the order execution WebSocket. Connect to it whenever you need to know what happened to your orders without polling REST. The same socket also carries the Order Execution commands and the Fee Quote (RFQ) stream — one connection, three surfaces.

Connecting

Authentication

Authenticate during the WebSocket upgrade using one of two methods. The presence of an X-Client-Id header switches the server into API-key mode; otherwise it expects a JWT. API Key Authentication: Include credentials as HTTP headers on the upgrade request:
If X-Client-Id is present but X-Api-Key or X-Api-Secret is missing or invalid, the upgrade is rejected with 401 Unauthorized. JWT Authentication: Use the standard Authorization header if your client supports it (non-browser clients):
Since browsers cannot set custom headers on WebSocket upgrades, pass the JWT via the Sec-WebSocket-Protocol header instead:
Encode the complete JWT as base64url without = padding. A Bearer.<standard base64> form is also accepted. The server echoes Sec-WebSocket-Protocol: authorization on the 101 response. JWTs that are expired or revoked (via /auth/logout) are rejected at upgrade time and revalidated while the connection remains open — see Errors & Disconnection.
Credentials are never read from query parameters. They leak into logs, browser history, and Referer headers, so that path was removed.

Welcome Message

On successful connection, the server sends:
polymarket_builder_code is the public attribution value required by the one-shot submit_signed_order flow. Include the identical value in the request and in the signed EIP-712 Order.builder field. All messages on this WebSocket are JSON text frames. Events are filtered per-user; you only receive events for your own orders and positions.

A complete connect example

API-key event filtering

API-key connections receive an event only when the key has the required scope and, for provider-scoped events, platform access to that event’s originating provider.
Gotcha: filtered-out events are silently omitted. The server does not send an error frame when a scope or platform check fails, so a missing event class looks identical to an idle account. API-key connections also do not receive in-app notification events or legacy status_changed events without an exchange_id. JWT connections are unaffected by scope and platform-event filtering.

Server Events

status_changed

Sent whenever an order transitions between statuses. Includes full order details so the frontend can update instantly without an HTTP fetch.
holding_wallet is the wallet that will hold the order’s shares. It matters when one market is held in two wallets (a Kairos wallet plus an imported predict.fun account, or a Polymarket EOA plus a Safe): those are two positions, and without this field an order shows under both.
Gotcha: holding_wallet is absent until the executor resolves it. An early queued/pending transition legitimately predates it, so treat “no holder” as “do not decide” and fall back to matching on market/token.
A failing transition may also carry error_code, error_classification, and a structured error_details object (the same contract as the REST POST /orders error response). All three are omitted when absent rather than sent as null. settlement_pending: true identifies an early off-chain match (currently predict.fun). Its quantities are provisional: show match feedback, but do not create a position or make those shares sellable. Authoritative status frames send false; older servers omit the field. A raw partial status alone cannot distinguish an early match from a settled partial fill.

partially_filled

Sent when part of your order fills. The order status becomes partial.

filled

Sent when an individual fill occurs on your order. Each fill has its own fill_id; partial fills from the same order share the same order_id.
fee is the Kairos platform fee for this fill. exchange_fee is the fee charged by the venue, normalized to USD. Both are non-negative decimal strings. exchange_fee is null when the venue does not report its fee on the live fill; the trade-history response is updated when reconciliation obtains it.

position_updated

Sent after a fill to reflect your updated position, including unrealized PnL.
Gotcha: position identity is (exchange_id, market_id, token_id, holding_wallet). The same market held in two wallets is two positions — a sell routes to one wallet — so keying on market/token alone merges the rows and double-counts shares. holding_wallet is null only for legacy rows with no holder recorded.
last_trade_at_ms is the millisecond-precision twin of the second-truncated last_trade_at; same-second updates are unorderable without it.

balance_updated

Sent when your wallet balance changes (after fills, deposits, etc.). Events with a tx_hash indicate on-chain balance changes; events without tx_hash are internal adjustments from trade execution. token is "usdc" or "pusd" on Polygon (chain: "polygon"), or "usdc" on Solana (chain: "solana"). "pusd" is the Polymarket V2 trading collateral — track pUSD balance events for up-to-date buying power.

failed

Sent when an order fails to execute.

setup_completed

Sent when an exchange setup (wallet preparation, token approvals) completes.

position_redeemed

Sent when a resolved market position is redeemed on-chain.
Match on token_id. condition_id is frequently empty on Polymarket position rows, so matching on it alone leaves a stale Redeem button.

position_resolved

Sent the instant an on-chain resolution is applied to a position you still hold. This is the only signal for a resolution that does not involve a redemption — position_updated carries no resolved flag and position_redeemed fires only on an actual redeem.
redeemable reflects the post-resolution state (a winner is true). For a retired loser net_size is "0" and realized_pnl carries the booked loss. Both are decimal strings.

ctf_action_completed

Sent when a conditional-token split or merge completes on-chain.

fill_hint

Optimistic, non-authoritative fill hint emitted from low-latency signal paths (e.g. mempool / preconfirmation watchers). Use it to nudge the UI early; do not treat it as a confirmed fill. A subsequent filled / partially_filled / status_changed event is the authoritative confirmation.
Deduplicate on idempotency_key. If no confirming event arrives within a reasonable window, treat the hint as expired and roll back any optimistic UI state.

notification

In-app notification pushed to the connected client.

Event Types Reference


Keepalive

Send a ping to keep the connection alive or measure latency:
The server responds:
The server also sends WebSocket-level Ping frames every 30 seconds to keep the connection alive through load balancers (the AWS load balancer idles a connection out at 60 seconds). Your WebSocket library should respond with Pong automatically; if it doesn’t, the server will eventually drop the connection. The server likewise answers a client-sent protocol-level Ping with a Pong. The same 30-second tick is where the server re-checks JWT expiry and session revocation, so a revoked identity is cut within one interval.

Delivery, ordering, and recovery

The event stream is a low-latency hint, not a ledger. REST (GET /orders, GET /positions) is the source of truth whenever you need certainty.

Order Lifecycle Examples

Limit order that fills over time
Market order that fills immediately
Internal statuses (queued, locked, executing, orphaned) are reported as pending in REST API responses but may appear as distinct status_changed transitions on the WebSocket.

Multiple Connections

If you have multiple connections open for the same user (e.g. across several tabs, processes, or devices), every connection receives every event for your user. Cross-instance relaying is de-duplicated by origin so one event is not fanned out twice.
Gotcha: events are not deduplicated per client. There is no per-client delivery ledger, so the same event can reach you more than once. Dedupe on the identifiers the events carry — fill_id, order_id, and idempotency_key on fill_hint / notification — rather than assuming exactly-once delivery.

Dropped events under lag

Gotcha: a slow reader silently loses events. Events are delivered from a bounded in-process broadcast channel. A connection that reads too slowly is not disconnected — it silently misses the events that aged out while it was behind, and delivery resumes with the newest ones. There is no gap marker on the wire, so reconcile over REST whenever correctness matters.

Errors & Disconnection

The order execution WebSocket can reject the upgrade, push a per-command error, or close the socket at any time. Clients MUST handle all three cases and reconnect with backoff.

Upgrade-time rejections (HTTP status before 101)

The response body is JSON: {"error":"<reason>", ...}. The connection-limit 429 additionally carries max_allowed and current_count.

Per-command errors (order_error frame)

Commands that fail (validation error, scope check, missing order, exchange error, etc.) return an order_error JSON frame on the same connection — see Order Execution → submit_order error response. The connection normally stays open; only the offending command is rejected. An expired/revoked JWT detected before a command is the exception: the server sends a 401 order_error and terminates the connection. Always correlate the response with your command via request_id. Programmatic clients should branch on error_details.code (when present) rather than the human-readable error string.

Malformed client messages

Invalid JSON and a missing or unknown type are silently ignored. If a recognized command has an invalid envelope — most commonly a missing or non-string request_id — you receive:
This silent-ignore behavior for unknown types is intentional for forward compatibility.

Server-initiated close codes

Gotcha: there is no custom auth close code on this socket. The periodic heartbeat detects an expired/revoked JWT within 30 seconds and sends Close(None). A pre-command check instead sends a 401 order_error, then terminates the connection without explicitly sending a close frame. In both cases, refresh the JWT before reconnecting.
  1. On any close, wait at least 500 ms before reconnecting; on repeated failures use exponential backoff up to 30 s with jitter.
  2. If reconnect returns 401, refresh your JWT (or API key) before the next attempt.
  3. After reconnecting, call GET /orders?status=live (and GET /positions) to reconcile any events that fired while you were disconnected — the WebSocket does not replay missed events.
  4. Continue matching incoming events to in-flight commands by request_id and client_order_id; idempotency on the server means re-submitting with the same client_order_id is safe.

Limits

Connection Limits

Connections are counted per user, so two API keys owned by the same account share one budget. Exceeding the limit returns 429 Too Many Requests on upgrade with a body of {"error":"Maximum WebSocket connections exceeded","max_allowed":N,"current_count":N}.

Best Practices

  • Always listen on the WebSocket for order status. Do not poll GET /orders in a tight loop.
  • The status_changed event is the primary status notification. filled and partially_filled provide fill-specific details.
  • Handle reconnections gracefully. On reconnect, call GET /orders?status=live to sync state for any events missed during the disconnection.
  • Listen for notification events to display user-facing alerts (e.g., fill confirmations, errors).
  • Use client_order_id on submissions and match it to WebSocket events for reliable tracking.