PolyDataFeed.xyz

API documentation

Connecting to the feed.

One WebSocket endpoint. One JSON object per text frame. No subscription handshake — trades start arriving the moment you connect.

Connect

The endpoint

wss://feed.polydatafeed.xyz/ws

Present your API key on connection. Anything else is optional. The first frame you receive confirms the stream:

{
  "type": "connected",
  "stream": "polymarket_onchain_trade_bundles",
  "message_format": "one JSON object per WebSocket text frame"
}

Python

# pip install websockets certifi
import asyncio, json, ssl, certifi, websockets

URL = "wss://feed.polydatafeed.xyz/ws"
KEY = "otf_your_key_here"

async def main():
    ctx = ssl.create_default_context(cafile=certifi.where())
    async with websockets.connect(URL, additional_headers={"x-api-key": KEY}, ssl=ctx) as ws:
        async for raw in ws:
            msg = json.loads(raw)
            if "sequence" not in msg:
                continue          # control frame, see "Message types"
            print(msg["sequence"], msg["side_name"], msg["avg_price_micros"] / 1e6)

asyncio.run(main())

Two things that catch people out. additional_headers was called extra_headers before websockets 14 — on an older release, use that name instead. And on macOS, Python installed from python.org often ships without a CA bundle, so every TLS connection fails with CERTIFICATE_VERIFY_FAILED until you pass certifi explicitly, as above.

Node

// npm install ws
const WebSocket = require("ws");

const ws = new WebSocket("wss://feed.polydatafeed.xyz/ws", {
  headers: { "x-api-key": "otf_your_key_here" }
});

ws.on("message", (data) => {
  const msg = JSON.parse(data);
  if (msg.sequence === undefined) return;   // control frame
  console.log(msg.sequence, msg.side_name, msg.avg_price_micros / 1e6);
});

Authentication

Three ways to present a key

All equivalent. The header is recommended.

MethodExampleNotes
x-api-keyx-api-key: otf_…Recommended. Never written to proxy logs.
AuthorizationAuthorization: Bearer otf_…Equivalent, if it suits your client better.
Query string?api_key=otf_…Convenient for a quick test. Ends up in access logs, so avoid it in production.

Keys look like otf_ followed by 43 URL-safe characters. We store only a hash, so a lost key cannot be recovered — we issue a new one instead.

Message types

Four things can arrive

Trades have a sequence field. Control frames have a type field instead. Checking for one or the other is enough to tell them apart.

FrameShapeMeaning
trade has sequence One completed trade. The field reference below describes it.
connected type: "connected" Sent once, immediately on connect.
warning type: "warning" Currently only client_lagged: your connection fell behind and the server dropped missed_messages frames for you.
closing type: "closing" We are about to close the connection. See close reasons below.

Trade field reference

One object per trade

Every amount in a trade is a scaled integer, so nothing is lost to float rounding on values you may be reconciling. Two scales are in play, and both use the same divisor:

  • PricesMillionths. 945000 ÷ 1,000,000 = a probability of 0.945.
  • SizesOutcome tokens have 6 decimals. 1450000 ÷ 1,000,000 = 1.45 shares — not 1.45 million.
  • FeesAlso 6-decimal, in whichever asset the fee was charged in.

Divide every size by 1,000,000 before showing it to anyone. A typical trade is single- or double-digit shares, so an unscaled integer looks plausibly like a large trade rather than obviously wrong.

{
  "timestamp": 1784102260549,
  "sequence": 1084883,
  "block_number": 78412009,
  "log_index": 214,
  "source": "live",
  "contract": "0xe2222d279d744050d28e00520010520000310F59",
  "transaction_hash": "0xd2b1f684…",
  "taker_order_hash": "0x2d5321a2…",
  "taker_order_maker": "0xd1A63A243042d864520512FbDbd2Fd20E0Ab1507",
  "side": 0,
  "side_name": "BUY",
  "token_id": "265548430863149056543016666115286240846765262642603455744107",
  "maker_amount_filled": 1370250,
  "taker_amount_filled": 1450000,
  "avg_price_micros": 945000,
  "max_price_traded_micros": 945000,
  "min_price_traded_micros": 945000,
  "fee_charged": 3760,
  "fill_count": 2,
  "fee_event_count": 1,
  "makers": [
    { "address": "0xf2cF3CF7…", "size": 950000, "price_micros": 945000, "side": 1, "side_name": "SELL" },
    { "address": "0x9b31Ac10…", "size": 500000, "price_micros": 945000, "side": 1, "side_name": "SELL" }
  ]
}
FieldTypeDescription
timestampintBlock time in epoch milliseconds.
sequenceintMonotonic counter, +1 per trade. Your completeness check.
block_numberintPolygon block the trade settled in.
log_indexintIndex of the boundary event within the block. With block_number, uniquely identifies the trade on chain.
sourcestringHow the trade reached us. Always "live" today. Read it rather than assuming — a future "backfill" value must not break your parser.
contractstringWhich Polymarket exchange contract matched the trade.
transaction_hashstringPolygon transaction containing the trade.
taker_order_hashstringHash of the taker's order.
taker_order_makerstringAddress that signed the taker order — the trade's initiator.
sideint0 buy, 1 sell, from the taker's perspective.
side_namestring"BUY" or "SELL". The same information, spelled out.
token_idstringERC-1155 token id of the outcome traded. A decimal string, because it exceeds a 64-bit integer.
maker_amount_filledintTotal filled on the maker side. 6-decimal integer — divide by 1,000,000 for shares.
taker_amount_filledintTotal filled on the taker side. 6-decimal integer — divide by 1,000,000 for shares.
avg_price_microsintSize-weighted average price × 1,000,000.
max_price_traded_microsint | nullHighest price among the fills. Null if unavailable.
min_price_traded_microsint | nullLowest price among the fills. Null if unavailable.
fee_chargedintFee taken, summed from the trade's FeeCharged events. 6-decimal integer, denominated in the asset the fee was charged in.
fill_countintHow many individual fills the trade was assembled from.
fee_event_countintHow many fee events were attributed to it.
makersarrayOne entry per counterparty: address, size (6-decimal shares), price_micros, side, side_name.

Detecting gaps

Verify completeness yourself

sequence increases by exactly one per trade while the feed is up, so comparing each message against the last proves nothing was lost in transit. It restarts from zero when the feed does — deliberately, because that reset is how you learn there was an outage. Trades from that window are not captured and are not replayed today. Handle both cases:

last = None
for msg in stream:
    seq = msg.get("sequence")
    if seq is None:
        continue
    if last is not None:
        if seq < last:
            # Counter reset: the feed restarted. Trades during the
            # outage are gone -- backfill from your own source if
            # you need them.
            on_feed_restart(last_seen=last)
        elif seq != last + 1:
            # seq - last - 1 trades never reached you
            alert(missing=seq - last - 1, after=last)
    last = seq

We may add recovery of missed trades after an outage later. If we do, they will arrive tagged source: "backfill" rather than "live" — which is why source is worth reading now, even though it only has one value today.

Heartbeat and reconnects

Staying connected

The server sends a WebSocket ping every 30 seconds and closes any connection that has not answered for 90. You almost certainly need no code for this — browsers and every mainstream client library reply to pings automatically, inside the library.

There is no subscription or resume protocol. If you are disconnected, reconnect and trades resume from that moment. We do not replay what you missed while away, so treat a reconnect as a small, known gap rather than something to reconcile.

  • PingEvery 30 seconds, from server to client.
  • TimeoutClosed after 90 seconds without a pong.
  • BackoffReconnect with a short backoff. A tight retry loop helps nobody.
  • UpstreamOur own connection to Polygon reconnects automatically; you will not see it.

Errors and close reasons

When something is refused

Rejected at connect — HTTP 401

The response carries an x-auth-error header naming the cause.

x-auth-errorMeaningWhat to do
invalidNo such key.Check for a truncated or mistyped key.
expiredThe key's term ended. The body names the date.Renewable — ask us to extend it. The same key will work again.
revokedThe key was withdrawn.Permanent. Stop retrying and get in touch.
account_disabledThe account is suspended.Get in touch.

Closed while connected

You receive a JSON frame explaining why, then the socket closes.

reasonMeaningRetry?
key_expiredThe key's term ended mid-session.Yes, once renewed — the same key is reactivated.
access_revokedThe key was revoked or the account disabled.No. Contact us.

The distinction matters for automated clients: key_expired is reversible and worth retrying after renewal, access_revoked is not.

Health and stats

Two open endpoints

Both are plain HTTPS GETs and need no key.

GET https://feed.polydatafeed.xyz/health
{"status":"ok"}

GET https://feed.polydatafeed.xyz/stats
{
  "status": "ok",
  "stats": {
    "last_sequence": 226212,
    "last_bundle_timestamp_ms": 1784550919000
  }
}

/health only says the process is running — it can answer ok while the connection to the chain is dead. Alert on /stats instead: compare last_sequence against the last one you received to see how far behind you are, and treat it no longer climbing as a stall. last_bundle_timestamp_ms is the block time of that trade, so its age tells you the same thing from a single reading.