Polymarket WebSocket API: Limits, Ping and Python

The Short Answer
Polymarket runs four WebSocket services, and the one people mean by "the Polymarket WebSocket API" is the public CLOB market channel at wss://ws-subscriptions-clob.polymarket.com/ws/market. It streams as soon as you send a two-field JSON subscribe message with assets_ids and type. Keep it open with the text frame PING every 10 seconds. No rate limits are published for that socket. Every number you will find quoted online governs an HTTP endpoint or comes from somewhere other than Polymarket.
Key Takeaways
- Four endpoints: CLOB market, CLOB user, RTDS at
wss://ws-live-data.polymarket.com, sports atwss://sports-api.polymarket.com/ws. Only the user channel takes credentials. - The market subscribe payload requires exactly
assets_idsandtype. Optional:initial_dump(true),level(1, 2 or 3, default 2),custom_feature_enabled(false). - Heartbeats differ:
PINGevery 10 seconds on market and user, every 5 on RTDS. Sports inverts it, sending lowercasepingand closing unless you replypongwithin 10 seconds. - The rate limits page documents no WebSocket limit at all: 9,000 requests per 10 seconds is the general HTTP CLOB budget, and every other number on it governs an HTTP endpoint too.
- No sequence numbers exist, and a
price_changeentry'ssizeis the new aggregate size, not an increment. A0deletes the level.
What is the Polymarket WebSocket API?
Polymarket ships four sockets across three hosts, each with its own subscribe convention and heartbeat rule. Getting the endpoint right is most of the battle, since the docs publish no error responses for the CLOB sockets, so a typo leaves you working backwards from a connection that never streams.
| Channel | Endpoint | Auth | Heartbeat |
|---|---|---|---|
| CLOB market | wss://ws-subscriptions-clob.polymarket.com/ws/market | None, public | Client PING 10s |
| CLOB user | wss://ws-subscriptions-clob.polymarket.com/ws/user | CLOB credentials in payload | Client PING 10s |
| RTDS | wss://ws-live-data.polymarket.com | None, public | Client PING 5s |
| Sports | wss://sports-api.polymarket.com/ws | None, public | Server ping, client pong |
The market channel carries public orderbook and trade activity for the token IDs you subscribe to. RTDS is a separate live-data feed on its own host, and the realtime data page lists its topics. Sports needs no subscribe message.
The user channel is the authenticated one, and its auth is unusual: no signed handshake, no header, no challenge frame. Standard L2 CLOB credentials ride inside the first subscribe message as an auth object, and the spec declares no security schemes, so that is the whole mechanism.
How do I subscribe to the Polymarket market channel?
The smallest frame that works is published verbatim in the docs. Token IDs are the long numeric strings, not the 0x condition ID, and you fetch them over HTTP first. Take the last path segment of the market's URL on polymarket.com and call https://gamma-api.polymarket.com/markets?slug=your-market-slug. The response carries clobTokenIds, one token ID per outcome, so a Yes/No market gives you two. Subscribe to a closed token and the socket opens and stays silent, so check the market is live before blaming your payload.
{"assets_ids": ["<token_id>"], "type": "market"}
A fuller subscribe frame sets both optional booleans explicitly:
{"assets_ids": ["65818619657568813474341868652308942079804919287380422192892211131408793125422"],
"type": "market", "custom_feature_enabled": true, "initial_dump": true}
What this does
type is the constant market. initial_dump defaults to true, and the schema publishes that default without describing the behavior, though a book snapshot is documented as arriving on subscribe. custom_feature_enabled defaults to false, and true unlocks the best_bid_ask, new_market and market_resolved events. level accepts 1, 2 or 3 and defaults to 2, with no meaning attached to any value beyond "Subscription level. Defaults to 2.", so omit it: 1 and 3 change something undocumented.
The user channel takes the same shape with different keys: auth and type are required, type is the constant user, and the optional markets array of condition IDs is omitted for events from every market.
{"auth": {"apiKey": "<uuid>", "secret": "...", "passphrase": "..."},
"type": "user", "markets": ["0xbd31dc8a20211944f6b70f31557f1001557b59905b7738480ca09bd4532f84af"]}
You never reconnect to change what you watch. Both channels accept update frames whose operation enum is exactly subscribe and unsubscribe, with markets in place of assets_ids on the user channel. They change the current connection only, so a rolling watchlist stays on one socket.
{"assets_ids": ["<new_token_id>"], "operation": "subscribe"}
{"assets_ids": ["<old_token_id>"], "operation": "unsubscribe"}
What are the Polymarket WebSocket rate limits?
Polymarket's rate limits page is entirely about HTTP. The words WebSocket and wss appear in its navigation, and not once in the limits themselves. No inbound-message limit, connection cap or maximum assets_ids per subscribe message is published. What does exist is easy to misattribute:
| Number | What it actually governs |
|---|---|
| 9,000 req / 10s | HTTP CLOB REST, general |
| 1,500 req / 10s | HTTP /book, and separately HTTP /price |
| 500 req / 10s | HTTP /books, and separately HTTP /prices |
Every number on that page governs an HTTP endpoint. Figures you will find quoted for WebSockets, such as a connection cap per IP, a ceiling on active subscriptions, or a subscription limit of 200, are not documented by Polymarket anywhere in the rate limits, real-time data or market channel pages. Treat them as folklore until Polymarket publishes one, and build backpressure around what your own client observes instead.
How does the Polymarket WebSocket ping work?
Polymarket's heartbeat is application-level, not WebSocket protocol ping frames. Most client libraries send protocol pings automatically and those do not count here, so send a text frame containing the literal characters PING.
The docs put it plainly: "Send the text frame PING every 10 seconds; the server replies with PONG." The user channel behaves identically and RTDS wants it every 5 seconds. Sports reverses direction and drops the case, sending ping every 5 seconds and closing unless you reply pong within 10 seconds, the only documented disconnect timeout on any Polymarket socket. Market and user publish no idle timeout, so send on schedule.
PONG arrives as a bare text frame that breaks any parser expecting an object, so short-circuit it at the top of your handler.
if raw == "PONG":
continue
How do I keep a Polymarket orderbook in sync over WebSocket?
Two event types build the book. book is a full snapshot, sent on subscribe or after a trade, so snapshots recur rather than arriving once. Its required fields are event_type, asset_id, market, bids, asks, timestamp and hash, where market is the condition ID and asset_id the token ID. The timestamp is Unix milliseconds as a string, and the numbers inside the book arrive as strings too:
{
"event_type": "book",
"asset_id": "65818619657568813474341868652308942079804919287380422192892211131408793125422",
"market": "0xbd31dc8a...4532f84af",
"bids": [{"price": "0.48", "size": "1200"}],
"asks": [{"price": "0.52", "size": "900"}],
"timestamp": "1739827200000",
"hash": "7f3c..."
}
Cast price and size before comparing them, or "0.9" sorts above "0.85". The spec does not document the order of the bids and asks arrays, so sort them yourself rather than assuming index 0 is best.
price_change is the delta update to price levels when an order is placed or canceled. Its price_changes array can batch several updates, and each entry carries the level's new size and a hash. The schema describes size as "New aggregate size (0 means level removed)", so it replaces the level outright and adding it to your existing size makes the book drift. Six lines cover both rules:
book = {"bids": {}, "asks": {}} # price string -> size string
def apply_snapshot(event): # a "book" event
book["bids"] = {lvl["price"]: lvl["size"] for lvl in event["bids"]}
book["asks"] = {lvl["price"]: lvl["size"] for lvl in event["asks"]}
def apply_change(side, price, size): # one entry of "price_changes"
if size == "0":
book[side].pop(price, None) # 0 means the level is gone
else:
book[side][price] = size # absolute replacement, never +=
A snapshot wins over anything you were holding, so call apply_snapshot on every book event, not just the first. Read the key names off a real price_changes entry before wiring apply_change up, since only the book event has a published required-field list.
hash means two things: on a book event it hashes the orderbook content, and on a price_changes entry it hashes the order that caused the change. No hashing algorithm is published.
There are no sequence numbers on either channel. No seq, sequence or update-ID field appears in any official AsyncAPI spec, so advice about comparing sequence numbers to spot a gap describes fields that do not exist. You get a fresh book snapshot after a trade instead, so an active market self-heals often. The docs publish no resync procedure beyond that, so the staleness policy is yours: resubscribe, or pull HTTP /book on an interval.
tick_size_change matters if you place orders. It fires when price approaches the limits of its range, and Polymarket's agent-skills documentation warns that orders are rejected at the old tick size. No threshold is published, so read the new size off the event rather than predicting the change.
last_trade_price is the fourth default market event and fires on every match, carrying the token's most recent traded price. It is not part of book maintenance, so route it separately rather than through your book and price_change branches. The user channel carries two event types, order and trade.
How do I use the Polymarket WebSocket in Python?
Confirm the socket is alive from your shell. Install the client with npm i -g wscat, connect, then paste the subscribe frame on one line, since wscat sends one frame per line and a pretty-printed payload goes out as fragments.
wscat -c wss://ws-subscriptions-clob.polymarket.com/ws/market
> {"assets_ids":["<token_id>"],"type":"market"}
A live token returns a book snapshot within a second or two. Silence means the token ID is wrong or the market has resolved, not that the endpoint is down. The Python version adds a heartbeat task you hold a reference to, a short-circuit on PONG, and tolerance for one event or a batch.
import asyncio
import json
import websockets
URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
TOKEN_ID = "65818619657568813474341868652308942079804919287380422192892211131408793125422"
async def heartbeat(ws):
while True:
await ws.send("PING") # uppercase text frame, every 10 seconds
await asyncio.sleep(10)
async def main():
async with websockets.connect(URL) as ws:
await ws.send(json.dumps({"assets_ids": [TOKEN_ID], "type": "market",
"custom_feature_enabled": True, "initial_dump": True}))
hb = asyncio.create_task(heartbeat(ws)) # hold the reference, an unheld task can be GC'd
try:
async for raw in ws:
if raw == "PONG": # bare text frame, never JSON
continue
payload = json.loads(raw)
events = payload if isinstance(payload, list) else [payload]
for event in events:
kind = event.get("event_type")
if kind == "book":
print("snapshot", event["asset_id"], event["timestamp"])
elif kind == "price_change":
for change in event["price_changes"]:
print(change) # size is the NEW aggregate size, "0" removes the level
elif kind == "tick_size_change":
print("refresh tick size before ordering")
finally:
hb.cancel()
asyncio.run(main())
What this does
It subscribes to one token ID with the extra events enabled and runs a background PING loop, held in hb so the garbage collector cannot take it and canceled in the finally block when the socket closes. The read loop drops PONG before parsing, then normalizes single events and batches into one list. These handlers only print, so swap the book and price_change branches for apply_snapshot and apply_change.
Where are the Polymarket WebSocket docs and GitHub examples?
Bookmark three sources. The realtime data page holds the heartbeat wording for every channel. The market channel reference lists the subscribe schema and event types. The raw AsyncAPI documents are the most precise, since they carry required-field lists, enums and defaults: asyncapi.json for market, asyncapi-user.json for user and asyncapi-sports.json for sports.
On GitHub, Polymarket's agent-skills repository has a websocket.md aimed at agent builders, and it is the source of the tick-size warning above. The official clients, py-clob-client and clob-client, are built around the REST endpoints and order signing, while the market socket is a plain public WebSocket, so the 30 lines above are the whole dependency. Idle timeouts and reconnect policy are unpublished rather than hidden.
How do I stream Polymarket and other venues on one WebSocket?
Everything above buys you one venue, and that cost compounds. Add Kalshi and you inherit a second endpoint, auth model, message vocabulary and heartbeat rule, all covered in the companion post on the Kalshi WebSocket API. Add two more and you run four reconnect loops.
Predictefy collapses that into one connection. A single WebSocket at /v1/stream covers 15+ venues on one normalized schema, so a price level is a price level wherever it came from. Reads live under https://data.predictefy.com behind a bearer key, on the path shape /api/{venue}/{verb}.
Authorization: Bearer pk_live_...
The Free plan is 0 USD and opens 2 concurrent streams with 25,000 credits a month, enough to port this Python script across and compare feeds side by side. Paid tiers start at 49 USD, and the current table lives on the pricing page. The Arbitrage API is free on every plan, and stored history covers 11 of those venues, so a replay and a live stream read the same shape. Client libraries are available in beta, and agents connect through the MIT-licensed MCP server with npx -y @predictefy/mcp@1.0.0-beta.6, which enforces one constraint: building an order and submitting it are always separate tools. Orders are signed client side, so key custody stays with you.
Frequently Asked Questions
Does the Polymarket WebSocket have a rate limit?
None is published for the CLOB market or user channels. Polymarket's rate-limits page covers HTTP only and never mentions WebSockets. The 9,000 requests per 10 seconds figure governs HTTP CLOB calls, as does every other number on that page. A per-IP connection cap for the WebSocket is not among them.
How often do I need to send PING to the Polymarket WebSocket?
Every 10 seconds on the market and user channels, as the uppercase text frame PING, and the server replies PONG. RTDS wants PING every 5 seconds. The sports channel inverts it: the server sends lowercase ping and closes the connection unless you answer pong within 10 seconds.
Does Polymarket send sequence numbers on the WebSocket?
No. No seq, sequence or update-ID field appears in any official AsyncAPI spec for the market, user or sports channels. The only per-message ordering fields are timestamp, in Unix milliseconds as a string, and hash. A fresh book snapshot follows a trade, though the docs publish no gap-detection or resync procedure beyond that.
What does custom_feature_enabled do in the Polymarket subscribe message?
It defaults to false, and setting it to true adds three market events: best_bid_ask, new_market and market_resolved. Leave it off and you receive only book, price_change, last_trade_price and tick_size_change. Set it in the first subscribe payload, beside assets_ids and type, since the update frame carries only assets_ids and operation.
How do I stream Polymarket data in Python?
Connect to the market endpoint with any standard client such as websockets, send a JSON frame carrying assets_ids and type set to market, then run a background task sending PING every 10 seconds. Keep a reference to that task so it survives. Skip any frame equal to PONG before parsing, since it arrives as plain text.