The Prediction Markets
Real-time

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

  1. Get stream ids for the outcomes you want from REST: event detail, markets or bulk prices.
  2. Connect with your API key. The server sends welcome.
  3. Send subscribe with those ids. You get subscribed, then a snapshot of current prices, then a quote every time a price changes.
  4. 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:

Client → server
{"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:

Server → client
{
  "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.

Client → server
{
  "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.

subscribed
{
  "type": "subscribed",
  "seq": 2,
  "id": "s1",
  "subscriptions": [
    "…"
  ],
  "active": 2,
  "limit": 100
}
snapshot
{
  "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

quote
{
  "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.

FieldMeaning
provider, market_id, outcome_idWhich outcome this is (the stream ids).
priceDisplayed price, 0 to 1.
best_bid, best_ask, spread, midpoint_priceTop of the order book.
last_trade_price, last_trade_size, last_trade_atMost recent trade.
depth_bids, depth_asks, recent_tradesBook depth and recent trades, when the venue provides them.
display_price_basisWhich rule produced price (the venue's own display rule).
source_timestamp, received_at, book_confirmed_at, latency_msVenue time, TPM receive time, last book confirmation, and the delay between them.
feed_statuslive, degraded or stale.
feed_stateok, degraded, down, unconfirmed or unknown: whether a healthy feed vouches for a quiet book.
quote_age_secondsSeconds since the book last changed.
freshness_statefresh, stale or unavailable.
versionChanges each time this outcome's state changes.

Sequence numbers, cursors and heartbeats

  • seq numbers every server message on a connection from 1, with no gaps. A gap means you dropped a message: send {"op": "resync", "id": "r1"}.
  • cursor is the position in TPM's quote stream (<ms>-<n>). It only increases on a connection, except after a snapshot with resync_required: true, whose cursor replaces yours.
  • heartbeat arrives every heartbeat_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 cursor from a quote, a heartbeat or a resync snapshot.
heartbeat
{
  "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).

Client → server
{
  "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.

resync snapshot
{
  "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"
}
reasonMeaning
retention_exceededYour cursor is older than the retained stream.
stream_resetThe stream restarted; old cursors are invalid.
catchup_limitMore than 5,000 entries behind; a snapshot is cheaper.
slow_consumerYou fell more than 5,000 entries behind while connected.
reader_restartedThe server's shared reader restarted.
client_requestYou 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.

PlanConnectionsSubscriptions
Free (default)2100
Pro51,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.
error
{
  "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 with reason: "client_request".
  • Errors echo your request id.
Error codeMeaning
auth_requiredNo key in the headers and no auth message within 10 s.
invalid_api_keyThe key is wrong, revoked, or the demo key.
connection_limitToo many open connections for this key.
subscription_limitThe subscribe would exceed your subscription limit.
invalid_subscriptionA subscription is missing ids or names an unknown provider.
invalid_cursorThe cursor is malformed or was sent on a later subscribe.
invalid_message, message_too_large, unknown_opThe message could not be understood.
not_subscribedYou unsubscribed from something you were not subscribed to.
rate_limitedMore than 20 messages in 10 s.
stream_unavailableThe quote stream is down. Reconnect with backoff and your cursor.
Close codeMeaning
1000Normal close.
1011Stream unavailable. Reconnect with backoff and your cursor.
4001Authentication failed.
4029Connection limit reached.

Complete client

Connects, subscribes, keeps a local price table, detects gaps and resumes after a disconnect.

Client
// 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();

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.