Halloween Sale: 35% off all Ryzen 9950X VPS plans

Use code Ends Oct 31 See Ryzen plans

Polymarket PolyBolt: how to move your bot’s price feeds off RTDS

Written by TradoxVPS Engineering Team
|
Polymarket PolyBolt migration: the RTDS price topics and the PolyBolt channels that replace them.

Polymarket PolyBolt is the WebSocket that now carries Polymarket’s crypto, equity and TWAP reference prices. It went live on 15 September 2026, and the same day Polymarket’s changelog described the older Real-Time Data Service, RTDS, as legacy for reference prices.

The old price topics are now on a timer. Polymarket’s migration guide says the deprecated RTDS price topics are “planned for removal one month after the 0.11.0 release” of its SDKs, and 0.11.0 reached npm and PyPI on 23 September 2026. No calendar date has been announced, but the arithmetic points to around 23 October 2026.

If a bot of yours reads crypto_prices, crypto_prices_chainlink, either TWAP topic or equity_prices from RTDS, it needs to move. This guide covers what maps to what, the Binance prices PolyBolt does not carry, authentication, the new message format, reconnect rules, the official SDKs and a raw-socket client we tested.

Current status

Last verified: 16 October 2026.

  • RTDS price topics: deprecated. Removal is planned one month after the 0.11.0 SDK release of 23 September 2026, so around 23 October 2026. No calendar date has been published.
  • PolyBolt: wss://ws-live-v2.polymarket.com/ws, live since 15 September 2026. The reference price channels need CLOB API credentials.
  • Default source: Chainlink wherever Chainlink serves the symbol. Crypto moved to this default on 2 October 2026 and equity on 29 September 2026.
  • Pyth: can be requested with a provider pin on btcusd, ethusd, solusd, xrpusd, dogeusd and bnbusd. A pin can fall back to Chainlink or be ignored, so check source. hypeusd and zecusd are Chainlink only.
  • Binance: not available on PolyBolt.
  • TWAP: 60-second window only. The 30-second topic has no replacement.
  • Staying on RTDS: the activity topic. The live comments stream is being retired with no streaming replacement.
  • SDKs: PolyBolt support since version 0.11.0 of polymarket-client (Python) and @polymarket/client (TypeScript). The latest release, 0.13.0 of 9 October 2026, added provider choice.
  • Old RTDS client library: @polymarket/real-time-data-client is marked deprecated on npm.

How to move from Polymarket RTDS to PolyBolt in six steps

  1. Get your CLOB API credentials (API key, secret and passphrase) into the process that reads prices. The reference price channels refuse anonymous subscriptions.
  2. Connect and authenticate. Open wss://ws-live-v2.polymarket.com/ws and send an auth frame before anything else.
  3. Subscribe to the new channels in one batch frame: price.crypto with {"symbol":"btcusd"} in place of crypto_prices and crypto_prices_chainlink, price.crypto.twap with window_seconds set to 60 in place of the 60-second TWAP, and price.equity in place of equity_prices.
  4. Parse the new messages. Take prices from full_accuracy_value as exact decimals, and read source on every payload.
  5. Handle snapshots and reconnects. Load the two-minute snapshot, watch seq and dropped, and reconnect according to the close code.
  6. Switch over before the removal. Run both feeds side by side, compare them, then delete the RTDS price subscriptions before the expected removal around 23 October 2026.

Polymarket’s official SDKs, from version 0.11.0, handle the connection, authentication and reconnects for you. The sections below cover each step in detail, with tested code.

What changes when Polymarket removes the RTDS price topics

RTDS carries five reference price topics. Four of them have a PolyBolt channel to move to, and one, the 30-second TWAP, has nothing. All five are on the same removal schedule, so a bot that reads any of them needs work before the removal date, not after it.

Three things are outside this change:

  • The activity topic. The migration guide says “Keep using RTDS for the activity topic”. It has no PolyBolt channel.
  • Comments. “The live comments stream is being retired without a replacement streaming channel.” Read comments through the REST listing instead, which the SDKs expose as listComments in TypeScript and list_comments in Python.
  • Your CLOB sockets. The market and user channels at wss://ws-subscriptions-clob.polymarket.com are a separate service. The migration guide does not touch them.

PolyBolt also has a public channel, price.polymarket, with the best bid and ask for each outcome token. It needs no credentials. It does not replace any RTDS topic, but it can be handy for a dashboard that should not hold trading credentials.

Polymarket RTDS to PolyBolt topic mapping

RTDS topicExample RTDS filterPolyBolt channelPolyBolt filterWhat changes
crypto_pricesbtcusdtprice.crypto{"symbol":"btcusd"}Source moves off Binance (to Chainlink by default) and the quote currency changes from USDT to USD
crypto_prices_chainlink{"symbol":"btc/usd"}price.crypto{"symbol":"btcusd"}Same source by default, new symbol format
crypto_prices_twap_sixty{"symbol":"btc/usd"}price.crypto.twap{"symbol":"btcusd","window_seconds":60}Exact decimal strings replace the E18 integers
crypto_prices_twap_thirty{"symbol":"btc/usd"}NoneNoneNo replacement
equity_prices{"symbol":"AAPL"}price.equity{"symbol":"aapl"}Lower-case symbols, and Chainlink by default where it serves the symbol (RTDS used Pyth)

Crypto symbols on PolyBolt are lower-case USD pairs with no slash, and the crypto channels list eight: btcusd, ethusd, solusd, xrpusd, dogeusd, bnbusd, hypeusd and zecusd. Equity symbols are lower-case tickers such as aapl. Polymarket’s SDKs reject btc/usd, btcusdt and BTCUSD with a UserInputError before anything is sent. A raw client gets no such check, so validate symbols yourself and alert on any subscription that produces no data.

Each symbol is its own subscription. RTDS let you omit the filter and receive every symbol on a topic; PolyBolt does not, and every channel and filter pair counts towards a limit of 64 per connection.

Polymarket RTDS to PolyBolt topic map: crypto_prices and crypto_prices_chainlink move to price.crypto, crypto_prices_twap_sixty to price.crypto.twap, equity_prices to price.equity, and crypto_prices_twap_thirty has no replacement.

Polymarket RTDS and PolyBolt compared

RTDSPolyBolt
Endpointwss://ws-live-data.polymarket.comwss://ws-live-v2.polymarket.com/ws
Access to price dataPublicCLOB API credentials, sent in an auth frame on each connection
Subscribe messageaction, with topic, type and a filter stringop, with channel and a filter object for each symbol
Symbolsbtcusdt, btc/usd, AAPLbtcusd, aapl
A quiet symbolSilenceOne snapshot frame per subscription, possibly empty
Price valueA number; the TWAP topics used E18 integersAn exact decimal string in full_accuracy_value, plus an approximate float
SourceFixed by the topicNamed in source on every payload
Sequence numbersNoneseq per channel, plus a dropped counter
HeartbeatThe client sends a text PING every 5 secondsThe server sends WebSocket pings every 25 seconds
LimitsNone stated64 subscriptions, 20 subscribe frames a second, 64 KB frames, 8 auth frames per connection
Close codesPlain 1006 disconnects4001, 4002, 4003 and 4008, each with a documented meaning

Binance prices are not on PolyBolt

PolyBolt has no Binance feed. Its provider pins accept chainlink and pyth only, and the migration guide notes that moving from btcusdt to btcusd changes both the source and the quote currency. Polymarket’s TypeScript SDK is blunt about it: Binance USDT prices and PolyBolt USD prices are “not a like-for-like replacement”.

What that means depends on why your bot read Binance in the first place.

If it only needed a quick reference price for BTC or ETH, price.crypto will usually do the job. Re-baseline anything you calibrated on Binance data, though: thresholds, spreads against the order book, volatility estimates. USDT is not USD, and one exchange’s prices are not an aggregated feed.

If your strategy depends on Binance itself, read Binance directly. When we checked on 9 October 2026, Polymarket’s hourly crypto up or down markets named Binance one-hour candles as their resolution source, and its daily markets a Binance one-minute candle. Our guide to Polymarket TWAP settlement covers which markets settle on what. For those markets, no PolyBolt channel is the settlement price.

Reading Binance directly

Binance’s own WebSocket market streams cover the usual needs:

  • btcusdt@aggTrade or btcusdt@trade for last-trade prices;
  • btcusdt@bookTicker for the best bid and ask;
  • btcusdt@kline_1h for the one-hour candle. Kline streams update every two seconds, and the candle is final only when its x field turns true.

A combined stream puts several on one socket:

wss://stream.binance.com:9443/stream?streams=btcusdt@bookTicker/btcusdt@kline_1h

Binance’s connection rules differ from Polymarket’s. Stream symbols are lower case. The server pings every 20 seconds and disconnects a client that sends no pong within a minute. Every connection is closed at the 24-hour mark, so plan a reconnect. A connection may send 5 messages a second and listen to up to 1,024 streams, and wss://data-stream.binance.vision serves market data only.

Binance does not serve every country. Read its terms for where you live before you connect, and do not use a server in another country to get round a restriction that applies to you. Our Polymarket crypto trading infrastructure guide covers running more than one price feed side by side.

Polymarket PolyBolt authentication: CLOB credentials on every connection

The four reference price channels are gated. In the words of Polymarket’s PolyBolt overview, “Subscriptions require CLOB API credentials.” These are the L2 credentials (an API key, a secret and a passphrase), created or derived with an L1 signature from your wallet, as Polymarket’s wallets and authentication page describes.

Send them in an auth frame as soon as the socket opens, wait for the reply, then subscribe:

{"op":"auth","rid":"a1","auth":{"apiKey":"...","secret":"...","passphrase":"..."}}

The server answers {"op":"authed","rid":"a1"}. Subscribe to a gated channel before that and you get an auth_required error instead.

Treat these credentials as trading secrets. Polymarket describes them as credentials “for authenticating private requests”, and that includes requests that place orders. Keep them out of logs, error reports and anything that runs in a browser.

There is an architecture point here too. The official SDKs build their secure client from your wallet’s private key (Python) or a signer (TypeScript), deriving the credentials if you do not pass them in. If your price process runs separately from your signing process, a raw socket needs only the three L2 values, which keeps the private key out of the feed handler.

Authentication errors and the eight-frame budget

The authentication errors each call for a different response:

ErrorMeaningWhat to do
auth_invalidThe credentials were refusedStop and fix them. Repeating the frame only spends attempts
auth_unavailablePolymarket could not verify the credentials just thenRetry later, on a fresh connection
auth_requiredYou subscribed to a gated channel before authenticatingAuthenticate first
auth_expiredA one-time ticket was used or unknownOnly affects the web app’s ticket sign-in, not API keys
auth_attemptsThe ninth auth frame on one connectionThe server closes with 4008

The fresh connection matters because each connection allows eight auth frames in its lifetime. While reviewing the PolyBolt pull request for NautilusTrader, an open-source trading platform, its maintainers found that repeated auth_unavailable replies could use up that budget. The fix recycles the socket and authenticates on a new connection after the retry delay. Polymarket’s Python SDK behaves the same way: it reconnects on auth_unavailable and stops on any other authentication error.

PolyBolt connection limits

The limits apply per connection, and every breach ends in a 4008 close:

LimitValueWhat happens when you exceed it
Subscriptions64 channel and filter pairssub_limit error, then close 4008
Subscribe or unsubscribe frames20 a second, and a batch counts oncerate_limited error, then close 4008
Frame size64 KBClose 4008
Auth frames8auth_attempts error, then close 4008

Batch your subscriptions into one frame, keep each frame well under 64 KB (Polymarket’s Python SDK stays below 60,000 bytes), and spread anything over 64 subscriptions across connections.

PolyBolt messages: acknowledgements, snapshots and exact prices

A batch subscribe looks like this:

{"op":"subscribe","rid":"s1","subscriptions":[
  {"channel":"price.crypto","filter":{"symbol":"btcusd"}},
  {"channel":"price.crypto.twap","filter":{"symbol":"btcusd","window_seconds":60}}
]}

Each item gets its own reply: {"op":"subscribed","channel":"price.crypto","rid":"s1"}, with a provider field when the server confirms which vendor it is serving, or an error frame if the item was rejected. The acknowledgement names the channel, not the symbol. Polymarket’s own Python SDK relies on the replies arriving in the order the items were sent, rejections included, so keep track of that order.

After each acknowledgement comes one snapshot frame, marked "snapshot": true, holding “the preceding two minutes of prices” in payload.data. The array is empty when there is no recent data. Live frames follow. Subscribing again to a channel and filter you already hold returns another acknowledgement but no new snapshot.

Here is a snapshot and a live frame for btcusd. The values are illustrative, and both frames parse with Polymarket’s Python SDK 0.13.0:

{"v":1,"channel":"price.crypto","seq":1,"ts":1792143000120,"snapshot":true,
 "payload":{"symbol":"btcusd","source":"chainlink","data":[
   {"timestamp":1792142999000,"value":64126.0,"full_accuracy_value":"64126.00000000"},
   {"timestamp":1792143000000,"value":64128.25,"full_accuracy_value":"64128.25000000"}]}}

{"v":1,"channel":"price.crypto","seq":2,"ts":1792143001050,
 "payload":{"symbol":"btcusd","value":64131.5,"full_accuracy_value":"64131.50000000",
            "timestamp":1792143001000,"source":"chainlink"}}

The envelope fields:

FieldMeaning
vProtocol version, always 1
channelprice.crypto, price.crypto.twap, price.equity or price.equity.twap (the public price.polymarket channel uses the same envelope)
seqConsecutive per connection and per channel, snapshots included. Resets on every reconnect
tsThe producer’s event time, in Unix milliseconds
snapshotPresent, and true, only on the snapshot frame
droppedFrames dropped on this channel since the last delivered frame because the client fell behind. Absent when zero
payloadOne price point, or the snapshot batch

Inside the payload, value is a float and only approximate. full_accuracy_value is the exact decimal as a string, and that is the one to parse, into Decimal in Python or a decimal library elsewhere. Polymarket’s Python SDK never uses the float. If your RTDS code converted TWAP integers from E18 fixed point, drop that step: PolyBolt’s TWAP values are plain decimal strings, with window_seconds set to 60.

Equity live frames can carry two more fields. received_at is when Polymarket ingested the price, and is_carried_forward is true when the market is closed and the price is carried over from the last session. A staleness check on equity has to allow for both.

Sequence numbers count per channel, and drops leave no gap

The seq counter belongs to the channel, not the symbol. Subscribe to btcusd and ethusd on price.crypto and both share one sequence. A contributor’s PolyBolt pull request for NautilusTrader reported the same from a live probe.

The server assigns seq at delivery. Frames it drops for a slow client never receive a number, so the loss shows up in the dropped field of the next frame, not as a jump in seq. Watch both. dropped tells you the server shed frames because your reader fell behind. A jump in seq should never happen on a healthy connection, so treat one as a fault in your own pipeline.

Track seq per connection and per channel. Through the SDKs, one subscription can span several connections (one for each provider choice, and more beyond 64 filters), so their seq values can interleave. Watch dropped there instead.

After a loss, record the gap rather than interpolating across it. To refill your history, unsubscribe and then subscribe the affected channel’s filters again: a repeat subscribe without the unsubscribe brings no new snapshot. If your reader keeps falling behind, the server closes the connection with 4002.

PolyBolt reconnects, heartbeats and close codes

This is where a straight port needs the most new code. PolyBolt documents a response for each close:

CloseMeaningWhat to do
4001Authentication failedFix the credentials before reconnecting
4002Slow consumer, or two missed pongsBack off with jitter. If it repeats, put fewer subscriptions on each connection
4003The server is drainingReconnect once, after a random delay between 0 and 10 seconds
4008Policy breach, such as a limit or the auth budgetFix the cause first. The reference says “fix the client; do not reconnect blindly”
1006Abnormal disconnectExponential backoff with full jitter, from 1 second up to a 30-second cap
HTTP 429 or 503 when connectingThe server is busyWait at least the Retry-After time, plus jitter

After any reconnect, start from the top: authenticate, resend every subscription with its provider pin, reset your seq tracking and rebuild history from the new snapshots. Read source again as well, because Polymarket notes that a symbol’s vendor can change.

The server sends a WebSocket ping every 25 seconds and closes with 4002 after two missed pongs. Most WebSocket libraries answer pings for you, but only while your code lets them run, so a blocked event loop or a long synchronous handler can miss them. Unlike RTDS, there is no text PING to send. You can send {"op":"ping"} and get {"op":"pong"} back if you want an application-level check. Both official SDKs do that every 30 seconds and treat 90 seconds without any inbound frame as a dead connection.

Connection health and data health are separate checks. Crypto channels update about once a second per symbol, so a symbol that goes quiet for several seconds deserves an alert even while the socket looks fine.

PolyBolt connection lifecycle: connect, authenticate with CLOB credentials, subscribe, receive an acknowledgement and a two-minute snapshot per item, then live prices, with the action for each close code.

Chainlink or Pyth: provider pins and the source field

PolyBolt serves Chainlink by default. In the overview’s words: “Omit provider to follow the default, which is Chainlink wherever Chainlink serves the symbol.” To ask for a vendor, add provider to the filter, with chainlink or pyth as the value:

{"channel":"price.crypto","filter":{"symbol":"btcusd","provider":"pyth"}}

Treat a pin as a request. A pin the server cannot serve for that symbol falls back to the symbol’s default provider, and the acknowledgement names the provider actually served. Polymarket’s TypeScript SDK adds that a pin “may fall back or be ignored when selection is disabled”, and that source “always identifies the actual producer”.

For crypto, six symbols can be pinned to Pyth: btcusd, ethusd, solusd, xrpusd, dogeusd and bnbusd. hypeusd and zecusd have no second provider, and price.crypto.twap is Chainlink only. For equity, Polymarket’s TypeScript SDK notes that “equity sources may change across reconnects”, and a third vendor name, massive, can appear on equity payloads.

In practice:

  • log source for each symbol on every connection, and alert when it changes;
  • keep the vendor you expect in configuration, not in your head;
  • check the acknowledgement’s provider, where the server sends one, against the pin you asked for.

The SDKs go a step further and give each provider choice its own connection, counting a subscription without provider as a choice of its own.

The 30-second TWAP has no PolyBolt replacement

crypto_prices_twap_thirty has no PolyBolt equivalent. The TWAP channel carries the 60-second window only: the reference says “Only 60 carries data today”. It accepts other positive values for window_seconds but sends nothing for them, so a port that keeps a 30-second window gets a valid subscription and then silence.

For most bots this changes nothing about settlement. No live crypto up or down market has settled on the 30-second window since 14 August 2026, and PolyBolt describes price.crypto.twap as “Chainlink time-weighted average prices used by crypto up/down market resolution”. Our TWAP settlement guide has the window history and how the price to beat is set.

One caution. Polymarket does not document whether each streamed point is exactly the value a market settles on. For audit-grade checks, compare against the resolution price after each market closes. For the signed reports themselves, go to Chainlink; our TWAP settlement guide covers that route.

Moving to PolyBolt with the official Polymarket SDKs

Polymarket’s SDKs added Polymarket PolyBolt support in version 0.11.0 and provider choice in 0.13.0. The Python package, polymarket-client, needs Python 3.11 or later. The TypeScript package, @polymarket/client, declares Node.js 24 or later. Release notes are in the SDK changelog.

The deprecated subscriptions still work in 0.13.0 and still route to RTDS, so they will stop working when the topics are removed, possibly without an error. Search your code for them now:

DeprecatedReplacement
Python CryptoPricesSpec(topic="prices.crypto.binance") or topic="prices.crypto.chainlink"CryptoPriceSpec(symbols=["btcusd"])
Python CryptoPricesChainlinkTwapSpec(window_seconds=60)CryptoTwapPriceSpec(symbols=["btcusd"])
Python EquityPricesSpec(symbol="aapl")EquityPriceSpec(symbol="aapl")
TypeScript prices.crypto.binance or prices.crypto.chainlinkprices.crypto
TypeScript prices.crypto.chainlink.twapprices.crypto.twap, with no window option
TypeScript prices.equity.pythprices.equity

The new subscriptions need the secure client, which means credentials. Both samples below also pass wallet, set to the wallet your account already trades from: the signer’s own address for a plain wallet, or your Polymarket Proxy, Safe or Deposit Wallet address. Leave it out and version 0.13.0 of either SDK assumes the signer’s default Deposit Wallet and tries to deploy it if it does not exist yet. That fails without a Builder or Relayer API key, and with one it creates a wallet you may not want.

Python

import asyncio
import os

from polymarket import AsyncSecureClient
from polymarket.streams import CryptoPriceSpec, CryptoTwapPriceSpec


async def main():
    client = await AsyncSecureClient.create(
        private_key=os.environ["POLYMARKET_PRIVATE_KEY"],
        wallet=os.environ["POLYMARKET_WALLET"],  # the wallet your account already uses
    )
    async with client:
        handle = await client.subscribe([
            CryptoPriceSpec(symbols=["btcusd", "ethusd"]),  # add provider="pyth" to pin Pyth
            CryptoTwapPriceSpec(symbols=["btcusd"]),          # the window is fixed at 60 seconds
        ])
        async with handle:
            async for event in handle:
                if event.type == "subscribe":  # two minutes of history after each (re)subscription
                    print("history", event.topic, event.payload.symbol, len(event.payload.data))
                    continue
                p = event.payload  # p.value is a Decimal, p.timestamp a UTC datetime
                print(event.topic, p.symbol, p.source, p.value, p.timestamp, event.seq)


asyncio.run(main())

To reuse credentials you already hold, pass credentials=ApiKeyCreds(apiKey=..., secret=..., passphrase=...) to create. The private key is still required.

TypeScript

import { createSecureClient } from '@polymarket/client';
import { privateKey } from '@polymarket/client/viem';

const client = await createSecureClient({
  signer: privateKey(process.env.POLYMARKET_PRIVATE_KEY),
  wallet: process.env.POLYMARKET_WALLET, // the wallet your account already uses
});

const prices = await client.subscribe([
  {
    topic: 'prices.crypto',
    symbols: ['btcusd', 'ethusd'],
    // Called on acceptance and after every reconnect. provider is undefined
    // when the server does not confirm provider selection.
    onSubscribed: ({ symbol, provider }) =>
      console.log('confirmed', symbol, provider ?? 'not reported'),
  },
  { topic: 'prices.crypto.twap', symbols: ['btcusd'] },
]);

for await (const event of prices) {
  if (event.type === 'subscribe') {
    // The previous two minutes, delivered after every (re)subscription.
    console.log('history', event.topic, event.payload.symbol, event.payload.data.length);
    continue;
  }
  const { symbol, source, value, timestamp } = event.payload; // value is an exact decimal string
  console.log(event.topic, symbol, source, value, timestamp, event.seq);
}

Run the sample as an ES module, because it uses top-level await. The viem adapter is an optional peer dependency, and the package also ships ethers-v5 and privy adapters.

In version 0.13.0 both SDKs authenticate each connection and reconnect with full jitter: 0 to 10 seconds after a 4003, and never after a 4001 or 4008. They keep prices as exact decimals, start history afresh when the source changes and spread more than 64 filters across connections. Both also batch subscriptions under the frame limit and send their own pings every 30 seconds. One thing neither can do is read faster than your loop. The Python handle keeps a bounded queue and drops the oldest event when it fills, so keep the work inside the loop light.

If your bot runs on someone else’s code

Not every bot talks to RTDS directly. If yours sits on a library, a framework or a hosted tool, the change still reaches you.

  • Polymarket’s old RTDS client. The @polymarket/real-time-data-client package on npm is now marked deprecated, with a notice pointing to Polymarket’s unified SDK. Its latest release, 1.4.0, dates from July 2025, before PolyBolt existed, so code built on it needs to move to @polymarket/client or to a raw socket.
  • NautilusTrader. When closing the pull request mentioned above on 12 October 2026, NautilusTrader’s maintainer carried its PolyBolt work onto the project’s develop branch. The newest tagged release we found, 2.0.0rc4 from 2 September, predates that, so check the release notes of the version you run before the removal date.
  • Other libraries and hosted tools. Anything that reads prices from ws-live-data.polymarket.com will stop receiving them. Ask the developer whether it has moved to PolyBolt. If the software runs on your own machine, expect it to ask for your CLOB API credentials, because the new price channels need them.

Moving a raw WebSocket client to PolyBolt (Python example)

If you run your own socket code, or work in a language without an official SDK, here is a compact Python client that follows the documented rules. We tested it against a local server built from Polymarket’s protocol documents, in eleven scenarios covering drains, policy closes, authentication errors, a rejected Binance-style symbol, refused handshakes, a broken sequence and a silent connection. It has not been run against Polymarket’s production servers for this article, so try it with your own credentials before you rely on it.

"""Minimal PolyBolt price client: authenticate, subscribe, read the snapshot and
live prices, watch seq and dropped, and reconnect according to the close code.
Python 3.11+ with websockets 13 or later (pip install websockets)."""

import asyncio
import json
import os
import random
import time
from decimal import Decimal

from websockets.asyncio.client import connect
from websockets.exceptions import ConnectionClosed, InvalidHandshake, InvalidStatus

URL = "wss://ws-live-v2.polymarket.com/ws"
SUBSCRIPTIONS = [  # at most 64 per connection
    {"channel": "price.crypto", "filter": {"symbol": "btcusd"}},
    {"channel": "price.crypto", "filter": {"symbol": "ethusd"}},
    {"channel": "price.crypto.twap", "filter": {"symbol": "btcusd", "window_seconds": 60}},
]
STALE_AFTER = 30  # seconds without any frame; crypto channels update about once a second
STOP_ON = {"auth_invalid", "bad_op", "bad_channel", "bad_filter",
           "sub_limit", "rate_limited", "auth_attempts"}


class Stop(Exception):
    """Reconnecting will not fix this: correct the credentials or the client first."""


class Retry(Exception):
    """The server could not verify the credentials: try again on a fresh connection."""


def on_price(channel, symbol, source, price, ts_ms, history=False):
    # Replace with your own handler, and keep it fast: a slow reader gets frames dropped.
    print(channel, symbol, source, price, ts_ms, "(history)" if history else "")


async def session(ws):
    creds = {"apiKey": os.environ["CLOB_API_KEY"], "secret": os.environ["CLOB_SECRET"],
             "passphrase": os.environ["CLOB_PASSPHRASE"]}
    await ws.send(json.dumps({"op": "auth", "rid": "a1", "auth": creds}))
    while True:
        reply = json.loads(await asyncio.wait_for(ws.recv(), 10))
        if reply.get("op") == "authed":
            break
        if reply.get("op") == "error":
            if reply.get("code") == "auth_unavailable":
                raise Retry("auth_unavailable")
            raise Stop(reply.get("code"))

    await ws.send(json.dumps({"op": "subscribe", "rid": "s1", "subscriptions": SUBSCRIPTIONS}))
    last_seq = {}  # per channel on this connection: seq counts per channel, not per symbol
    while True:
        msg = json.loads(await asyncio.wait_for(ws.recv(), STALE_AFTER))
        op = msg.get("op")
        if op == "subscribed":  # one per item, in request order; names the channel, not the symbol
            print("subscribed", msg["channel"], "provider:", msg.get("provider", "not reported"))
        elif op == "error":
            if msg.get("code") in STOP_ON:
                raise Stop(msg.get("code"))
            print("server error", msg)
        elif msg.get("v") == 1:
            channel, seq, p = msg["channel"], msg["seq"], msg["payload"]
            if msg.get("dropped"):  # the server skipped frames because this client fell behind
                print(f"{channel}: {msg['dropped']} frames dropped before seq {seq}")
            prev = last_seq.get(channel)
            if prev is not None and seq != prev + 1:
                print(f"{channel}: seq jumped from {prev} to {seq}")
            last_seq[channel] = seq
            if msg.get("snapshot"):  # the previous two minutes, possibly empty
                for point in p["data"]:
                    on_price(channel, p["symbol"], p["source"],
                             Decimal(point["full_accuracy_value"]), point["timestamp"], True)
            else:
                on_price(channel, p["symbol"], p["source"],
                         Decimal(p["full_accuracy_value"]), p["timestamp"])


def backoff(code, attempt):
    if code == 4003:  # server draining: reconnect once after a random 0 to 10 seconds
        return random.uniform(0, 10)
    return random.uniform(0, min(30, 2 ** attempt))  # full jitter, capped at 30 seconds


async def run():
    attempt = 0
    while True:
        code, started = None, time.monotonic()
        try:
            async with connect(URL) as ws:
                try:
                    await session(ws)
                finally:
                    code = ws.close_code
        except Stop as err:
            print("stopped:", err)
            return
        except InvalidStatus as err:  # the HTTP handshake was refused
            status = err.response.status_code
            if status not in (429, 503) and status < 500:
                print("handshake refused with HTTP", status)
                return
            wait = err.response.headers.get("Retry-After", "")
            floor = int(wait) if wait.isdigit() else 0  # Retry-After is the minimum wait
            await asyncio.sleep(max(floor, backoff(None, attempt)) + random.uniform(0, 1))
            attempt += 1
            continue
        except (ConnectionClosed, InvalidHandshake, OSError, TimeoutError, Retry) as err:
            print("connection ended:", type(err).__name__, code)
        if code in (4001, 4008):
            print(f"closed with {code}: fix the credentials or the client before reconnecting")
            return
        if time.monotonic() - started > 60:
            attempt = 0
        await asyncio.sleep(backoff(code, attempt))
        attempt += 1


if __name__ == "__main__":
    asyncio.run(run())

A few notes before you adapt it:

  • The handler. on_price is where your bot takes over. Keep it quick and hand heavy work to a queue, because the reader also has to keep up with frames and pings.
  • Rejected filters stop the client. A bad_filter or bad_channel is a configuration mistake, and reconnecting would only repeat it.
  • The stale timer. STALE_AFTER reconnects a socket that has gone quiet. Raise it if you subscribe only to equity, which behaves differently outside market hours.
  • Refilling history. If you keep rolling windows, add the unsubscribe and subscribe step from the sequence numbers section when dropped appears.
  • More than 64 subscriptions. Run one session per group of 64 or fewer, each on its own connection.

RTDS to PolyBolt switch-over checklist

  1. Find every RTDS price subscription. Search for crypto_prices, equity_prices and ws-live-data.polymarket.com. In SDK code, search for prices.crypto.binance, prices.crypto.chainlink, prices.equity.pyth, CryptoPricesSpec, CryptoPricesChainlinkTwapSpec and EquityPricesSpec.
  2. Get CLOB credentials into the feed process safely. Use environment variables or a secrets manager, never source control.
  3. Run PolyBolt next to RTDS now. Log both for the same symbols. Chainlink spot and the 60-second TWAP should track each other closely across the two sockets, so investigate any lasting difference. For Binance topics, measure the basis you are taking on before you commit.
  4. Re-baseline anything calibrated on the old source. That covers thresholds, spreads and volatility estimates built on Binance USDT or on Pyth equity prices.
  5. Add alerts. Alert on stale prices per symbol, on any change in source, on dropped, and on seq jumps within one connection. Add a kill switch that stops quoting when the reference price is stale.
  6. Test the reconnect path on purpose. Kill the socket mid-stream and confirm the bot authenticates, resubscribes and rebuilds from the snapshot without a person stepping in.
  7. Remove the RTDS price subscriptions once PolyBolt has run cleanly. Keep RTDS only for the activity topic, if you use it.
  8. Check again after the removal date that nothing in your stack still expects RTDS prices.

Where to run the PolyBolt feed handler

PolyBolt adds one more long-lived, authenticated socket to a bot that probably already holds CLOB market and user connections. What matters most here is the reconnect path and the alerting around it. Run the feed handler on the same machine or network as the code that places orders, so that a stale price stops trading instead of feeding stale numbers into it.

If you are choosing where to host the bot, our guide to the best server location for a Polymarket bot covers the trade-offs, and our guide to running a Polymarket bot 24/7 covers restarts and monitoring. You can measure your own route with our latency checker. For the rest of the API, from order placement to rate limits, see our Polymarket API guide for trading bots.

Frequently asked questions

What is Polymarket PolyBolt?

PolyBolt is Polymarket’s WebSocket for real-time reference prices. It launched on 15 September 2026 and carries crypto prices, the 60-second Chainlink TWAP that Polymarket’s 5-minute, 15-minute and 4-hour crypto up or down markets resolve on, equity, forex and commodity prices, and forex TWAPs, along with a public best bid and ask channel. The reference price channels need CLOB API credentials. It replaces the price topics on Polymarket’s older RTDS socket.

When will Polymarket remove the RTDS price topics?

Polymarket says the deprecated RTDS price topics are planned for removal one month after its 0.11.0 SDK release. That release reached npm and PyPI on 23 September 2026, which points to around 23 October 2026. Polymarket has not published a calendar date, so move before then rather than waiting for a notice.

Do I need Polymarket API keys to use PolyBolt?

For the reference price channels, yes. The crypto, crypto TWAP, equity and equity TWAP channels all need your CLOB API credentials, meaning the API key, secret and passphrase, sent in an auth frame on each connection before you subscribe. The best bid and ask channel is public. Treat the credentials as trading secrets, because they authenticate private requests on your account.

Does PolyBolt carry Binance prices?

No. Its provider choices are Chainlink and Pyth, and its crypto prices are quoted in US dollars, so moving off the RTDS Binance topic changes both the source and the quote currency. If your strategy depends on Binance prices, for example because a market settles on a Binance candle, take them from Binance’s own market data streams where Binance is available to you.

Are PolyBolt crypto prices from Chainlink or Pyth?

Chainlink, by default, on every crypto symbol since 2 October 2026. You can pin Pyth on btcusd, ethusd, solusd, xrpusd, dogeusd and bnbusd by adding a provider to the filter, but a pin the server cannot serve falls back to the default, and where provider selection is switched off the pin is ignored. Every payload carries a source field, and that field is the reliable answer for your connection.

Is there a 30-second TWAP on PolyBolt?

No. The TWAP channel carries the 60-second window only. It accepts other window values but sends no data for them, so a client still asking for 30 seconds gets a valid subscription and then silence. No live crypto up or down market has settled on the 30-second window since 14 August 2026.

Why does PolyBolt close my connection with 4008?

4008 is a policy close. It follows a breach of the connection limits: a 65th subscription, more than 20 subscribe or unsubscribe frames in a second, a frame over 64 KB, or a ninth auth frame. Reconnecting will not help until the client is fixed, so log any error frame that arrived before the close, correct the cause, then connect again.

What stays on RTDS?

The activity topic. Polymarket’s migration guide says to keep using RTDS for it. The live comments stream is being retired without a streaming replacement, so read comments through the REST listing instead. The CLOB market and user WebSockets are a separate service and are not part of this migration.

How we checked this

On 16 October 2026 we read Polymarket’s migration guide, PolyBolt overview, PolyBolt reference, the AsyncAPI document published on the PolyBolt host, the changelog and the SDK changelog. We read the source of polymarket-client 0.13.0 and the type definitions of @polymarket/client 0.13.0, checked release dates on PyPI and npm, and confirmed that the newest canary build, from 15 October, had not changed the price topics. We also read NautilusTrader’s pull request, migration issue and release list, the npm record of Polymarket’s old RTDS client, and Binance’s stream documentation.

The example frames parse with the Python SDK’s own parser, the TypeScript sample type-checks against 0.13.0, the Python SDK sample runs against the SDK’s own event objects, and the raw client passed eleven scenarios against a local server built from the documented protocol. We did not connect to Polymarket’s production servers for this article.

Where Polymarket’s pages disagree, we went with the most recent. The reference still describes the equity channel as Pyth prices, while the changelog and the overview give Chainlink as the default, and the commodity symbols differ between the overview and the AsyncAPI document, which is why this guide lists none.

Share this article:
Facebook
X
LinkedIn

TradoxVPS Engineering Team

Infrastructure specialists focused on low-latency trading VPS and CME-proximal hosting.
Published:
Discover how TradoxVPS can power your trading with speed, stability, and 24/7 uptime to stay ahead in the markets.