Live quotes WebSocket
Stream price changes for Kalshi and Polymarket contracts over one connection instead of polling. Endpoint wss://api.thepredictionmarkets.com/api/public/v1/stream, protocol tpm.quotes.v1.
How it works
- Get
streamids for the outcomes you want from REST: event detail, markets or bulk prices. - Connect with your API key. The server sends
welcome. - Send
subscribewith those ids. You getsubscribed, then asnapshotof current prices, then aquoteevery time a price changes. - Keep the latest
cursor. If the connection drops, reconnect and resume from it without missing a change.
Connect and authenticate
Send your key in the upgrade request as X-API-Key: … or Authorization: Bearer …. Clients that cannot set headers, such as browsers, send an auth message within 10 seconds of connecting instead:
{"op":"auth","api_key":"YOUR_TPM_API_KEY"}Use your own key
Keys in the query string are not accepted. The public demo key cannot open streams: create a free key. Keep your key on a server; anyone can read a key shipped in browser code.
The server replies with welcome, including your limits and the heartbeat interval:
{
"type": "welcome",
"seq": 1,
"protocol": "tpm.quotes.v1",
"connection_id": "9f2c6e1a",
"heartbeat_interval_seconds": 15,
"limits": {
"tier": "beta",
"max_connections": 2,
"max_subscriptions": 100,
"max_subscriptions_per_message": 100,
"max_messages_per_10s": 20
},
"server_time": "2026-10-21T08:40:00+00:00"
}Subscribe
A subscription is one venue market, optionally narrowed to one outcome. Copy the stream object from any v1 outcome: {provider, market_id, outcome_id}. For Kalshi that is the market ticker and "yes"; for Polymarket, the condition id and the CLOB token id. Leave out outcome_id to get every outcome of that market. There is no unfiltered firehose.
{
"op": "subscribe",
"id": "s1",
"subscriptions": [
{
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes"
},
{
"provider": "polymarket",
"market_id": "0x0f49db97f71c68b1e42a6d16e3de93d85dbf7d4148e3f018eb79e88554be9f75",
"outcome_id": "54533043819946592547517511176940999955633860128497669742211153063842200957669"
}
],
"snapshot": true
}Replies, in order: subscribed (what was added and your active count), a snapshot of current state, then quote messages as prices change. Send "snapshot": false to skip the snapshot.
{
"type": "subscribed",
"seq": 2,
"id": "s1",
"subscriptions": [
"…"
],
"active": 2,
"limit": 100
}{
"type": "snapshot",
"seq": 3,
"id": "s1",
"cursor": "1792537467582-0",
"resync_required": false,
"reason": null,
"subscriptions": [
{
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes"
}
],
"count": 1,
"quotes": [
{
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes",
"price": 0.16,
"best_bid": 0.16,
"best_ask": 0.17,
"...": "..."
}
],
"server_time": "2026-10-21T08:40:00+00:00"
}To stop, send {"op": "unsubscribe", "id": "u1", "subscriptions": [...]}. The server answers unsubscribed.
Quote messages
{
"type": "quote",
"seq": 4,
"cursor": "1792537467600-0",
"quote": {
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes",
"price": 0.16,
"best_bid": 0.16,
"best_ask": 0.17,
"spread": 0.01,
"midpoint_price": 0.165,
"last_trade_price": 0.16,
"last_trade_at": "2026-10-21T08:39:58Z",
"received_at": "2026-10-21T08:40:01Z",
"feed_status": "live",
"feed_state": "ok",
"quote_age_seconds": 1,
"freshness_state": "fresh",
"...": "more fields, see the list below"
},
"server_time": "2026-10-21T08:40:01+00:00"
}Each quote is the full current state of one outcome. Apply it by replacing whatever you hold for (provider, market_id, outcome_id). Missing numbers are null, never 0.
| Field | Meaning |
|---|---|
provider, market_id, outcome_id | Which outcome this is (the stream ids). |
price | Displayed price, 0 to 1. |
best_bid, best_ask, spread, midpoint_price | Top of the order book. |
last_trade_price, last_trade_size, last_trade_at | Most recent trade. |
depth_bids, depth_asks, recent_trades | Book depth and recent trades, when the venue provides them. |
display_price_basis | Which rule produced price (the venue's own display rule). |
source_timestamp, received_at, book_confirmed_at, latency_ms | Venue time, TPM receive time, last book confirmation, and the delay between them. |
feed_status | live, degraded or stale. |
feed_state | ok, degraded, down, unconfirmed or unknown: whether a healthy feed vouches for a quiet book. |
quote_age_seconds | Seconds since the book last changed. |
freshness_state | fresh, stale or unavailable. |
version | Changes each time this outcome's state changes. |
Sequence numbers, cursors and heartbeats
seqnumbers every server message on a connection from 1, with no gaps. A gap means you dropped a message: send{"op": "resync", "id": "r1"}.cursoris the position in TPM's quote stream (<ms>-<n>). It only increases on a connection, except after a snapshot withresync_required: true, whose cursor replaces yours.heartbeatarrives everyheartbeat_interval_seconds(15 s) with the latest cursor for which everything you subscribed to has been sent. Three missed heartbeats means the connection is dead.- Your resume token is the newest
cursorfrom aquote, aheartbeator a resync snapshot.
{
"type": "heartbeat",
"seq": 5,
"cursor": "1792537467600-0",
"server_time": "2026-10-21T08:40:15+00:00"
}Reconnect and replay
Reconnect, authenticate, and pass your resume token on the first subscribe of the new connection, with the same subscriptions. The server replays every missed change in order (no snapshot).
{
"op": "subscribe",
"id": "s1",
"subscriptions": [
{
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes"
}
],
"cursor": "1792537467600-0"
}If the gap can no longer be replayed you get a snapshot with resync_required: true and a reason. Replace all your local state with it.
{
"type": "snapshot",
"seq": 2,
"id": "s1",
"cursor": "1792537999000-0",
"resync_required": true,
"reason": "retention_exceeded",
"subscriptions": [
{
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes"
}
],
"count": 1,
"quotes": [
{
"provider": "kalshi",
"market_id": "KXPRESNOMD-28-GN",
"outcome_id": "yes",
"price": 0.17,
"...": "..."
}
],
"server_time": "2026-10-21T09:10:00+00:00"
}| reason | Meaning |
|---|---|
retention_exceeded | Your cursor is older than the retained stream. |
stream_reset | The stream restarted; old cursors are invalid. |
catchup_limit | More than 5,000 entries behind; a snapshot is cheaper. |
slow_consumer | You fell more than 5,000 entries behind while connected. |
reader_restarted | The server's shared reader restarted. |
client_request | You sent resync. |
A cursor is rejected on later subscribes of a live connection; new subscriptions there get their own snapshot.
Limits
Limits are per API key, across all your connections.
| Plan | Connections | Subscriptions |
|---|---|---|
| Free (default) | 2 | 100 |
| Pro | 5 | 1,000 |
- Up to 100 subscriptions per message, 20 client messages per 10 seconds per connection, and 16 KB per message.
- A subscribe that would go over your limit is rejected as a whole.
- A dropped connection frees its slot immediately; a crashed server's slots free within 45 seconds.
{
"type": "error",
"seq": 9,
"id": "s2",
"code": "subscription_limit",
"message": "This API key allows 100 subscriptions across its connections",
"limit": 100,
"active": 100,
"requested": 3
}Other messages and errors
{"op": "ping", "id": "p1"}→{"type": "pong", "id": "p1"}.{"op": "resync", "id": "r1"}→ a snapshot of all your subscriptions withreason: "client_request".- Errors echo your request
id.
| Error code | Meaning |
|---|---|
auth_required | No key in the headers and no auth message within 10 s. |
invalid_api_key | The key is wrong, revoked, or the demo key. |
connection_limit | Too many open connections for this key. |
subscription_limit | The subscribe would exceed your subscription limit. |
invalid_subscription | A subscription is missing ids or names an unknown provider. |
invalid_cursor | The cursor is malformed or was sent on a later subscribe. |
invalid_message, message_too_large, unknown_op | The message could not be understood. |
not_subscribed | You unsubscribed from something you were not subscribed to. |
rate_limited | More than 20 messages in 10 s. |
stream_unavailable | The quote stream is down. Reconnect with backoff and your cursor. |
| Close code | Meaning |
|---|---|
1000 | Normal close. |
1011 | Stream unavailable. Reconnect with backoff and your cursor. |
4001 | Authentication failed. |
4029 | Connection limit reached. |
Complete client
Connects, subscribes, keeps a local price table, detects gaps and resumes after a disconnect.
// Node 18+: npm install ws
import WebSocket from 'ws';
const URL = 'wss://api.thepredictionmarkets.com/api/public/v1/stream';
const SUBSCRIPTIONS = [
{ provider: 'kalshi', market_id: 'KXPRESNOMD-28-GN', outcome_id: 'yes' },
];
const quotes = new Map(); // "provider|market_id|outcome_id" -> latest quote
const key = (q) => `${q.provider}|${q.market_id}|${q.outcome_id}`;
let cursor = null; // resume token for reconnects
function connect() {
let lastSeq = 0;
const ws = new WebSocket(URL, { headers: { 'X-API-Key': process.env.TPM_API_KEY } });
ws.on('message', (raw) => {
const msg = JSON.parse(raw);
if (lastSeq && msg.seq !== lastSeq + 1) {
ws.send(JSON.stringify({ op: 'resync', id: 'r1' })); // we missed a message
}
lastSeq = msg.seq;
switch (msg.type) {
case 'welcome':
ws.send(JSON.stringify({
op: 'subscribe', id: 's1', subscriptions: SUBSCRIPTIONS,
...(cursor ? { cursor } : { snapshot: true }),
}));
break;
case 'snapshot':
if (msg.resync_required) { quotes.clear(); cursor = msg.cursor; }
for (const q of msg.quotes) quotes.set(key(q), q);
break;
case 'quote':
quotes.set(key(msg.quote), msg.quote);
cursor = msg.cursor;
console.log(msg.quote.provider, msg.quote.market_id, msg.quote.price, msg.quote.feed_status);
break;
case 'heartbeat':
cursor = msg.cursor;
break;
case 'error':
console.error(msg.code, msg.message);
break;
}
});
ws.on('close', () => setTimeout(connect, 2000)); // reconnect, then resume from cursor
ws.on('error', () => ws.close());
}
connect();Related
Build with TPM
Get your free API key
One key gives you Kalshi and Polymarket events, prices with freshness, price history, cross-venue gaps and the live quotes WebSocket.