# How to subscribe to the Polymarket WebSocket channels

> Polymarket uses two WebSocket systems: CLOB streams order books and authenticated order activity, while RTDS streams external prices and comments. CLOB expects text `PING` every 10 seconds; RTDS expects it every 5 seconds.

- Source: https://papaproxy.net/blog/polymarket-websocket-api.php
- Published: 2026-08-07
- Author: Alex Young
- Category: API & data · PapaProxy.net Blog

---

## Key takeaways

- Polymarket has separate CLOB and RTDS WebSockets: CLOB market/user connections use 10-second text `PING`s, while RTDS requires them every 5 seconds.
- The public CLOB market channel subscribes by outcome-token `assets_ids`; the authenticated user channel filters by condition IDs and uses L2 API credentials.
- `book` is a full order-book snapshot, `price_change` changes individual levels, and `size: "0"` removes a level; `last_trade_price` is not a replacement book snapshot.
- After reconnecting, invalidate the old book and wait for a fresh `book` snapshot before applying deltas because the documented feed does not provide a universal monotonic sequence number.
- Monitor data freshness separately from heartbeat health: an open socket and successful `PONG`s do not prove that the application feed is still delivering useful events.

## Two hosts that are easy to confuse

There is no single **WebSocket API endpoint** for every live Polymarket event. The CLOB socket belongs to the trading stack; RTDS is a separate **real-time data feed** for external prices and comments.

| Stream | Endpoint | Subscription key | Auth | Heartbeat |
| --- | --- | --- | --- | --- |
| CLOB market | `wss://ws-subscriptions-clob.polymarket.com/ws/market` | token / asset IDs | No | `PING` every 10s |
| CLOB user | `wss://ws-subscriptions-clob.polymarket.com/ws/user` | condition IDs | L2 API credentials | `PING` every 10s |
| RTDS | `wss://ws-live-data.polymarket.com` | topic + type + filters | Usually public; selected streams may use `gamma_auth` | `PING` every 5s |

The current **WebSocket documentation** describes CLOB `market` as the public Level 2 feed for book, price and trade updates. The `user` channel carries activity tied to your own API credentials. RTDS uses a different message model and currently documents crypto prices, Chainlink crypto prices, equity prices and comments.

This distinction became more important after CLOB V2 went live on April 28, 2026. The production CLOB still uses `clob.polymarket.com`, but legacy V1 SDKs and V1-signed orders are no longer supported. Old 2024 or 2025 examples may therefore show a recognizable WebSocket host while surrounding it with obsolete trading or authentication code.

The identifiers are another common source of mistakes. The public market channel subscribes with `assets_ids`, meaning the individual outcome-token IDs. The authenticated user channel filters with `markets`, which are condition IDs. Sending a condition ID where the market socket expects a token ID does not describe the same object.

## Subscribing to the market channel

Use the public market channel when you need the **order book over WebSocket**, best prices, price-level changes or executions for specific outcome tokens. No wallet or API key is required.

A minimal subscription is:

JSONCopy code

```json
{
  "type": "market",
  "assets_ids": [
    "TOKEN_ID_YES",
    "TOKEN_ID_NO"
  ],
  "custom_feature_enabled": true
}
```

`custom_feature_enabled` is optional. Turning it on adds `best_bid_ask`, `new_market` and `market_resolved`; the core book events do not depend on it. You can also add or remove asset IDs on an existing connection with `operation: "subscribe"` and `operation: "unsubscribe"` instead of reconnecting.

The event types solve different parts of a local-book implementation:

| Event | Meaning | What your client should do |
| --- | --- | --- |
| `book` | Full aggregated snapshot | Replace local state for that asset |
| `price_change` | Order placement or cancellation changed levels | Apply each level update |
| `last_trade_price` | A trade was executed | Record the execution; do not treat it as a full book |
| `tick_size_change` | Minimum tick changed | Update order-price validation |
| `best_bid_ask` | Top of book changed | Optional shortcut when custom features are enabled |

A `price_change` with `size: "0"` removes that price level. That detail matters more than array ordering: a safe local book stores levels by price and recomputes the best bid and ask instead of assuming that every incoming array happens to be sorted exactly how your code expects.

The public **trades payload structure** is the `last_trade_price` event. It contains fields such as `asset_id`, `market`, `price`, `size`, `side`, `timestamp` and, in the current API reference, `transaction_hash`. It is not the same thing as the authenticated `trade` event on the user socket, which tracks the lifecycle of your own matched trade.

Send the text message:

TextCopy code

```text
PING
```

every 10 seconds. The CLOB server responds with `PONG`. This is an application-level heartbeat; a WebSocket library's protocol-level ping frames are a different mechanism.

## The authenticated user channel

The user socket is for your own order and trade updates. **WebSocket authentication** uses the L2 CLOB credentials `apiKey`, `secret` and `passphrase`, not the wallet private key itself.

Those L2 credentials are created or derived through L1 authentication, where the wallet signs an EIP-712 message. Polymarket then uses the API key, secret and passphrase for L2 authentication. Never put them into frontend JavaScript or a public repository.

A subscription looks like this:

JSONCopy code

```json
{
  "type": "user",
  "markets": [
    "0xCONDITION_ID"
  ],
  "auth": {
    "apiKey": "YOUR_API_KEY",
    "secret": "YOUR_API_SECRET",
    "passphrase": "YOUR_PASSPHRASE"
  }
}
```

The identifiers differ from the public channel deliberately: `assets_ids` on `market` are outcome-token IDs, while `markets` on `user` are condition IDs. The user connection can also subscribe or unsubscribe from markets without reconnecting.

There are two main event families.

`order` reports placement, partial matching/update and cancellation. The event's `type` distinguishes states such as `PLACEMENT`, `UPDATE` and `CANCELLATION`.

`trade` follows execution farther than a public last-price event. The documented lifecycle includes:

TextCopy code

```text
MATCHED → MINED → CONFIRMED
    ↓        ↑
RETRYING ───┘
    ↓
  FAILED
```

`MATCHED` therefore should not automatically be treated as final settlement. `CONFIRMED` and `FAILED` are terminal in the documented trade-state model.

Authentication failures deserve explicit logging. In a February 2026 GitHub issue, a Polymarket maintainer noted that failed authentication could cause the server to close a user WebSocket without first sending a useful error message. That is field evidence rather than an API contract, but it gives you a practical first check when a user socket closes immediately: validate freshly derived credentials before rebuilding the rest of the client.

## RTDS is a different socket with different rules

RTDS connects to:

TextCopy code

```text
wss://ws-live-data.polymarket.com
```

Its subscription message does not use CLOB token or condition IDs. You send an action plus one or more topic subscriptions:

JSONCopy code

```json
{
  "action": "subscribe",
  "subscriptions": [
    {
      "topic": "crypto_prices",
      "type": "update",
      "filters": "btcusdt,ethusdt"
    }
  ]
}
```

Current RTDS documentation describes `crypto_prices` from Binance, `crypto_prices_chainlink` from Chainlink, `equity_prices` and `comments`. Binance symbols use lowercase concatenated names such as `btcusdt`; Chainlink uses forms such as `btc/usd`, and its symbol filter is encoded differently.

Messages share an envelope:

JSONCopy code

```json
{
  "topic": "crypto_prices",
  "type": "update",
  "timestamp": 1753314088421,
  "payload": {
    "symbol": "btcusdt",
    "timestamp": 1753314088395,
    "value": 67234.50
  }
}
```

RTDS requires a text `PING` every **5 seconds**, not every 10. Subscriptions can be changed without disconnecting.

Heartbeat success is not the same as data freshness. A socket can remain technically open while the data your application expects becomes stale. An issue against Polymarket's official RTDS client describes exactly that failure shape: ping/pong continued while application messages stopped. Treat that report as a reason to monitor `last_data_age` separately from `last_pong_age`, not as a guarantee that every connection will fail the same way.

A useful monitor therefore has at least:

TextCopy code

```text
connection_age_seconds
last_pong_age_seconds
last_data_age_seconds
messages_received_total{topic,type}
reconnect_total{reason}
parse_error_total{topic,type}
```

The stale-data threshold must match the feed. A quiet comments subscription and a high-frequency BTC price subscription cannot use the same timeout.

## Streaming or polling: choosing between them

Use **REST and WebSocket endpoints** for different jobs instead of replacing every HTTP call with a socket.

For continuous order-book state, WebSocket is the natural path. The current **order book documentation** exposes `GET /book` for one token and `POST /books` for multiple token IDs; these REST calls are useful for one-off snapshots, startup checks and diagnostics. CLOB read endpoints are public.

A practical split looks like this:

| Need | Better source |
| --- | --- |
| Discover markets and obtain metadata | Gamma / market REST APIs |
| One current book snapshot | CLOB REST `/book` or `/books` |
| Continuous book changes | CLOB `market` WebSocket |
| Your order/fill lifecycle | CLOB `user` WebSocket |
| External crypto/equity reference prices | RTDS |
| Comments | RTDS |
| Historical prices | CLOB REST price-history endpoint |

Polling `/book` continuously throws away one of the main advantages of the stream: after the initial snapshot, most order placements and cancellations can be represented as level changes rather than another complete HTTP response.

The reverse mistake is forcing one-off discovery through a persistent socket. Market metadata and static identifiers do not need a long-lived connection simply because your execution process already has one.

## Reconnecting without losing the book

Good **WebSocket support** is not just `while True: reconnect()`. The important question is what state your process trusts after the connection comes back.

Current Polymarket market messages document timestamps and hashes, but they do not expose a monotonic sequence number that a client can use as a universal gap detector. Do not invent one from timestamps: multiple events can share close timestamps, and network arrival order is not a replacement for a documented sequence.

For the CLOB book, use this recovery rule:

1. The moment the socket is lost, mark every book owned by that connection as unsynchronized.
2. Reconnect with backoff and jitter.
3. Resubscribe to the same token IDs.
4. Ignore `price_change` events for an asset until a fresh `book` event has established its new full snapshot.
5. Replace the old local book with that snapshot, then resume applying deltas.
6. Treat REST `/book` as a diagnostic or fallback snapshot, not as permission to merge an unknown WebSocket gap blindly.

Polymarket documents `book` as a full snapshot emitted when first subscribing, which makes that event the clean handoff point after a reconnect.

Here is a production-oriented skeleton for the public market channel. It handles application-level heartbeats, invalidates state across reconnects and refuses to apply deltas before a fresh snapshot:

PythonCopy code

```python
import asyncio
import json
import random
from decimal import Decimal

from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed

URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
ASSET_IDS = ["TOKEN_ID_YES", "TOKEN_ID_NO"]

class Books:
    def __init__(self):
        self.data = {}
        self.synced = set()

    def invalidate_all(self):
        self.synced.clear()

    def apply(self, msg):
        event = msg.get("event_type")

        if event == "book":
            asset = msg["asset_id"]
            self.data[asset] = {
                "bids": {
                    Decimal(x["price"]): Decimal(x["size"])
                    for x in msg["bids"]
                },
                "asks": {
                    Decimal(x["price"]): Decimal(x["size"])
                    for x in msg["asks"]
                },
            }
            self.synced.add(asset)
            return

        if event != "price_change":
            return

        for change in msg["price_changes"]:
            asset = change["asset_id"]
            if asset not in self.synced:
                continue

            side = "bids" if change["side"] == "BUY" else "asks"
            price = Decimal(change["price"])
            size = Decimal(change["size"])
            levels = self.data[asset][side]

            if size == 0:
                levels.pop(price, None)
            else:
                levels[price] = size

async def heartbeat(ws):
    while True:
        await asyncio.sleep(10)
        await ws.send("PING")

async def stream():
    books = Books()
    backoff = 1.0

    while True:
        try:
            async with connect(
                URL,
                ping_interval=None,   # Polymarket uses text PING/PONG here
                open_timeout=10,
                close_timeout=5,
            ) as ws:
                books.invalidate_all()
                await ws.send(json.dumps({
                    "type": "market",
                    "assets_ids": ASSET_IDS,
                }))

                ping_task = asyncio.create_task(heartbeat(ws))

                try:
                    async for raw in ws:
                        if raw == "PONG":
                            continue
                        books.apply(json.loads(raw))
                finally:
                    ping_task.cancel()

            backoff = 1.0

        except (ConnectionClosed, OSError, TimeoutError, ValueError):
            books.invalidate_all()
            await asyncio.sleep(random.uniform(0, backoff))
            backoff = min(backoff * 2, 30)

asyncio.run(stream())
```

What to watch: an asset enters `synced` only after a new full `book` snapshot. A `price_change` received before that point is discarded rather than applied to stale state. `Decimal` avoids introducing binary-float rounding into price-level keys.

What this example deliberately leaves out: persistent storage, metrics export, graceful process shutdown, RTDS handling, authenticated user events and REST snapshot fallback. It also assumes that `ASSET_IDS` were already discovered and validated before the process starts.

For RTDS, use the same reconnect principles but restore topic subscriptions instead of token IDs, send the heartbeat every five seconds, and monitor application-data age independently from `PONG`.

The network path comes after those software controls. A proxy does not repair a stale local book, bad credentials or a missing heartbeat. When several independent long-running collectors need fixed, reproducible egress addresses or separate network failure domains, [crypto proxies](/crypto-proxy.php) can provide that stable egress layer without changing Polymarket's application rules.
