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.
| Method | Example | Notes |
|---|---|---|
| x-api-key | x-api-key: otf_… | Recommended. Never written to proxy logs. |
| Authorization | Authorization: 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.
| Frame | Shape | Meaning |
|---|---|---|
| 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" }
]
}
| Field | Type | Description |
|---|---|---|
| timestamp | int | Block time in epoch milliseconds. |
| sequence | int | Monotonic counter, +1 per trade. Your completeness check. |
| block_number | int | Polygon block the trade settled in. |
| log_index | int | Index of the boundary event within the block. With block_number, uniquely identifies the trade on chain. |
| source | string | How the trade reached us. Always "live" today. Read it rather than assuming — a future "backfill" value must not break your parser. |
| contract | string | Which Polymarket exchange contract matched the trade. |
| transaction_hash | string | Polygon transaction containing the trade. |
| taker_order_hash | string | Hash of the taker's order. |
| taker_order_maker | string | Address that signed the taker order — the trade's initiator. |
| side | int | 0 buy, 1 sell, from the taker's perspective. |
| side_name | string | "BUY" or "SELL". The same information, spelled out. |
| token_id | string | ERC-1155 token id of the outcome traded. A decimal string, because it exceeds a 64-bit integer. |
| maker_amount_filled | int | Total filled on the maker side. 6-decimal integer — divide by 1,000,000 for shares. |
| taker_amount_filled | int | Total filled on the taker side. 6-decimal integer — divide by 1,000,000 for shares. |
| avg_price_micros | int | Size-weighted average price × 1,000,000. |
| max_price_traded_micros | int | null | Highest price among the fills. Null if unavailable. |
| min_price_traded_micros | int | null | Lowest price among the fills. Null if unavailable. |
| fee_charged | int | Fee taken, summed from the trade's FeeCharged events. 6-decimal integer, denominated in the asset the fee was charged in. |
| fill_count | int | How many individual fills the trade was assembled from. |
| fee_event_count | int | How many fee events were attributed to it. |
| makers | array | One 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-error | Meaning | What to do |
|---|---|---|
| invalid | No such key. | Check for a truncated or mistyped key. |
| expired | The key's term ended. The body names the date. | Renewable — ask us to extend it. The same key will work again. |
| revoked | The key was withdrawn. | Permanent. Stop retrying and get in touch. |
| account_disabled | The account is suspended. | Get in touch. |
Closed while connected
You receive a JSON frame explaining why, then the socket closes.
| reason | Meaning | Retry? |
|---|---|---|
| key_expired | The key's term ended mid-session. | Yes, once renewed — the same key is reactivated. |
| access_revoked | The 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.