Free trial · 7 days

Get a free key

We email you a link; opening it shows your API key. Then message @oddspush on Telegram and we activate your 7-day free trial. No card.

By continuing you agree to the Terms and the Privacy Policy.

Already have a key?

Lost your key?

Live Pinnacle odds in Node.js

Last updated

To get live Pinnacle odds in Node.js, open a WebSocket to wss://feed.oddspush.com/v1/stream with the ws package, send a login with your OddsPush key, then a subscribe with the sports you want. Every price change arrives as an odds message about 30 ms after Pinnacle publishes it (bots hosted in Europe). Keep a book in a Map keyed by event id and market key, and apply a market only when its version is higher.

This guide builds that client step by step. The finished program, one feed.mjs file, is the same reference client as in the docs, listed in full at the end. If you write Python, the same guide is in Pinnacle odds in Python.

1. What you will build: a live Pinnacle odds feed in Node.js

One ES module, feed.mjs, that:

  • connects to the OddsPush stream and logs in with your key;
  • subscribes to soccer on the ld and both channels, the price Pinnacle displays;
  • keeps a correct book of every open market, from a snapshot plus every update after it;
  • asks for a fresh snapshot when it falls behind or the feed comes back;
  • reconnects with backoff, and stops on the close codes that retrying cannot fix.

Change one list and it follows any of the 13 sports (sport ids, coverage). You need Node.js 18 or newer and an OddsPush key. Every plan includes REST; the WebSocket stream comes with the free trial and with REST + WebSocket (pricing).

2. Install the ws package

The client needs one dependency, ws. Name the file .mjs (or set "type": "module" in package.json) so import works, and keep the key out of your code:

install Shell
npm init -y
npm install ws
shell Shell
export ODDSPUSH_KEY=opk_...
node feed.mjs

Your key is opk_ and 43 characters, shown once after sign-up, and works from one IP at a time. A new account receives no data until its free trial is switched on: message @oddspush on Telegram. Until then the stream answers unauthorized (4101). Details in Your key.

3. Connect to the live odds WebSocket and log in

One endpoint, TLS only. Turn per-message compression off (perMessageDeflate: false: it only adds latency on large frames) and accept messages up to 16 MB, since a snapshot part always holds at least one whole event. Send the login as soon as the socket opens: you have 15 seconds.

connect.mjs JavaScript
import WebSocket from 'ws';

const ws = new WebSocket('wss://feed.oddspush.com/v1/stream', {
  perMessageDeflate: false,
  maxPayload: 16 * 1024 * 1024,
});

ws.on('open', () => {
  ws.send(JSON.stringify({ type: 'login', token: process.env.ODDSPUSH_KEY }));
});
ws.on('message', (raw) => console.log(JSON.parse(raw.toString()))); // welcome, or an error
ws.on('close', (code) => console.log('closed', code));

The first message back is welcome, with your plan and its limits:

welcome JSON
{
  "type": "welcome",
  "server_time": 1793458714075,
  "feed": {"alive": true, "ready": true},
  "limits": {
    "queue_size": 1000,
    "keepalive_s": 15,
    "max_events": 1000,
    "snapshot_buffer_bytes": 4194304,
    "max_lag_s": 15
  },
  "account": {
    "id": "acme",
    "plan": "paid",
    "expires_at": 1796050714075,
    "limits": {
      "connections": 1,
      "sports": null,
      "streams": null,
      "snapshots_per_hour": 12,
      "snapshot_mb_per_hour": 400,
      "reconnects_per_minute": 12,
      "rest": true,
      "stream": true
    }
  }
}

A refused login gets one error, then the close with its code. Every field is in Connect and log in.

4. Subscribe to live Pinnacle odds by sport

Send the subscribe right after the login, without waiting for welcome. It adds to your filter and, by default, brings a snapshot of what it adds.

feed.mjs · excerpt JavaScript
const SUBSCRIBE = {
  type: 'subscribe',
  sports: [1],               // soccer
  channels: ['ld', 'both'],  // the displayed price; dz stays out
  snapshot: true,            // the default, spelled out
};
  • Always set channels. Without it the volatile dz arrives too, and both carries matches that never appear on ld.
  • Pre-match prices travel on the pre channel: add it if you want them, and keep them in a book of their own.
  • Single events go in events: up to 1,000 across all your subscribes.

Each update then arrives as odds, with Pinnacle's record in data:

odds JSON
{
  "type": "odds",
  "data": {
    "type": "live",
    "sport_id": 1,
    "topic": "matchups/reg/sp/29/live/ld",
    "op": "upd",
    "rec": {
      "id": 1638780974,
      "parentId": 1638780960,
      "type": "matchup",
      "startTime": "2026-10-31T15:00:00Z",
      "league": {"id": 1980, "name": "England - Premier League"},
      "participants": [
        {
          "name": "Brentford",
          "alignment": "home",
          "state": {"score": 1}
        },
        {"name": "Fulham", "alignment": "away", "state": {"score": 0}}
      ],
      "state": {"minutes": 38},
      "periods": [
        {"period": 0, "status": "open"},
        {"period": 1, "status": "open"}
      ],
      "markets": [
        {
          "key": "s;0;m",
          "type": "moneyline",
          "period": 0,
          "status": "open",
          "version": 3772905262,
          "isAlternate": false,
          "prices": [
            {"designation": "home", "price": -238},
            {"designation": "away", "price": 655},
            {"designation": "draw", "price": 318}
          ],
          "limits": [{"type": "maxRiskStake", "amount": 3000}]
        },
        {
          "key": "s;0;s;-0.75",
          "type": "spread",
          "period": 0,
          "status": "open",
          "version": 3772905262,
          "isAlternate": false,
          "prices": [
            {"designation": "home", "price": -105, "points": -0.75},
            {"designation": "away", "price": -115, "points": 0.75}
          ]
        },
        {
          "key": "s;0;ou;2.5",
          "type": "total",
          "period": 0,
          "status": "open",
          "version": 3772905263,
          "isAlternate": false,
          "prices": [
            {"designation": "over", "price": 104, "points": 2.5},
            {"designation": "under", "price": -122, "points": 2.5}
          ]
        }
      ]
    },
    "ts": 1793458714075
  }
}

5. Keep a correct odds book

The rules are the same in any language. Key events by rec.id and markets by key. Apply a market only if its version is higher: updates can arrive out of order, and a snapshot can interleave with them. Drop it when its status is not "open" or its period is closed or settled, and never use rec.version. None of these raises an error when broken: you just end up with wrong prices.

feed.mjs · excerpt JavaScript
// book: event id -> { rec: event fields, markets: Map(key -> market), seen: Map(key -> tick), tick }
const book = new Map();
let tick = 0;            // bumped on every record applied
let sweepFrom = null;    // tick when the snapshot being served was asked for
let sweepNext = null;    // the same, for one asked while another is in flight

function applyRec(rec) {
  tick += 1;
  let ev = book.get(rec.id);
  if (!ev) {
    ev = { rec: {}, markets: new Map(), seen: new Map(), tick: 0 };
    book.set(rec.id, ev);
  }
  ev.tick = tick;
  const { markets, ...fields } = rec;
  Object.assign(ev.rec, fields);
  for (const m of markets || []) {
    const key = m.key;
    if (key === undefined) continue;
    ev.seen.set(key, tick);              // mentioned, even if not newer
    const prev = ev.markets.get(key);
    if (prev && Number.isInteger(m.version) && Number.isInteger(prev.version)
        && m.version <= prev.version) {
      continue;                          // older than what we hold: skip
    }
    if (m.status === 'open') ev.markets.set(key, m);
    else ev.markets.delete(key);         // anything but "open" closes it
  }
  const periods = Array.isArray(rec.periods) ? rec.periods : Object.values(rec.periods || {});
  for (const p of periods) {
    if (p && (p.status === 'closed' || p.status === 'settled')) {
      for (const [key, m] of ev.markets) {
        if (String(m.period) === String(p.period)) ev.markets.delete(key);
      }
    }
  }
}

A snapshot only lists open markets, so a close you missed while disconnected never shows up in it. Note when you ask for a snapshot of everything you follow, and on its final part drop what nothing has mentioned since:

feed.mjs · excerpt JavaScript
// A snapshot lists only open markets: a close you missed never shows up in it.
// So note when you ask for one, and on its final part drop what nobody mentioned since.
// Call when you ask for a snapshot of everything you follow: the first subscribe on a
// connection, or a bare snapshot. Not for a later subscribe, nor for a snapshot with its
// own sports or channels: those cover a subset.
function snapshotAsked() {
  if (sweepFrom === null) sweepFrom = tick;
  else if (sweepNext === null) sweepNext = tick; // served after the one in flight
}

// On snapshot_rate_limited. It is about the next request in the order you made them, and it
// can arrive before the final part of the one still being delivered.
function snapshotRefused() {
  if (sweepNext !== null) sweepNext = null;
  else sweepFrom = null;
}

function snapshotDone() {              // on the final part
  const since = sweepFrom;
  sweepFrom = sweepNext;
  sweepNext = null;
  if (since === null) return;
  for (const [id, ev] of book) {
    if (ev.tick <= since) { book.delete(id); continue; } // not in the snapshot, no update since
    for (const key of ev.markets.keys()) {
      if ((ev.seen.get(key) ?? 0) <= since) {            // closed while we were not looking
        ev.markets.delete(key);
        ev.seen.delete(key);
      }
    }
  }
}

The full recipe, with the reasons, is in Keeping a correct book.

6. Route each message type

An odds message carries either a live update or a partial state. Handle op: "del" before looking at the channel: it removes the whole event, and it comes on whichever channel the event used last.

feed.mjs · excerpt JavaScript
function onOdds(data) {
  if (data.type === 'live') {
    const rec = data.rec || {};
    if (data.op === 'del') book.delete(rec.id);
    else if (rec.id !== undefined && PRICE.test(data.topic)) applyRec(rec); // other channels pass any filter
  } else if (data.type === 'snapshot') {
    // A partial state that can arrive at any time: merge, never sweep.
    for (const rec of data.events || []) applyRec(rec);
  }
  // other data.type values: skip
}
  • snapshot: apply each part; on "final": true, sweep.
  • missed: you fell behind. Ask for a snapshot.
  • feed with "up": the feed is back. Ask for a snapshot.
  • keepalive comes every 15 s of idleness, so 40 s without any message means the connection is dead.

7. Reconnect with backoff and handle close codes

Before closing, the server sends an error naming the reason. Four codes will not clear up by retrying, so the client stops: 4101 unauthorized, 4103 subscription_expired, 4107 replaced (your key connected from another IP) and 4109 stream_not_in_plan. For anything else, back off exponentially with jitter (1 to 30 s), reset only after 60 s connected, and honour retry_after_s after reconnect_rate_limited:

feed.mjs · excerpt JavaScript
  ws.on('close', (code) => {
    clearTimeout(silence);
    if (STOP.has(code)) {
      console.error('closed with', code, '- not reconnecting');
      process.exitCode = 1;
      return;
    }
    if (openedAt && Date.now() - openedAt > STABLE_MS) delay = BACKOFF_MIN_MS;
    const wait = Math.max(delay / 2 + Math.random() * (delay / 2), retryAfterMs);
    retryAfterMs = 0;
    delay = Math.min(delay * 2, BACKOFF_MAX_MS); // grow from the last delay, capped
    console.info('closed with', code, '- reconnecting in', Math.round(wait), 'ms');
    setTimeout(connect, wait);
  });

On reconnect, log in and subscribe again; keep your book, and sweep it on the final part of the new snapshot. All the codes are in Errors and close codes.

8. Convert American odds to decimal

Prices come in American odds, as Pinnacle quotes them:

odds.mjs JavaScript
// American odds to decimal. Guard against a missing price first.
const toDecimal = (american) => (american > 0 ? 1 + american / 100 : 1 + 100 / -american);

toDecimal(-238); // 1.4202
toDecimal(655); // 7.55

To take the margin out as well, see Pinnacle no-vig odds, or ask the stream for it with {"type": "subscribe", "fair": true}.

9. Or poll the Pinnacle odds REST API with fetch

If you only need the current book now and then, skip the stream. Same key, in the x-token header; only GET. /v1/events lists the events with the number of open markets per channel, and /v1/events/{event_id} returns one event with merged: ld and both merged by version.

poll.mjs JavaScript
// Node 18 or newer: fetch is built in. Save as poll.mjs.
const BASE = 'https://feed.oddspush.com/v1';
const KEY = process.env.ODDSPUSH_KEY;

async function get(path) {
  const r = await fetch(BASE + path, { headers: { 'x-token': KEY } });
  if (!r.ok) throw new Error(r.status + ' ' + path); // a 429 carries retry-after
  return r.json();
}

const { events } = await get('/events?sport_id=1'); // soccer
for (const row of events.filter((r) => r.ld || r.both).slice(0, 5)) {
  const ev = await get('/events/' + row.event_id);
  for (const [key, m] of Object.entries(ev.merged)) { // ld and both, merged by version
    console.log(ev.event_id, key, m.prices.map((x) => [x.designation, x.price]));
  }
}
  • Rate limited per account: a burst, then a steady rate per minute. A 429 carries retry-after in seconds.
  • Poll from the same machine as your stream. A request from a new IP takes over the key and closes the stream with replaced (4107).
  • REST is as fresh as you poll. The REST plan is $69 a month; REST + WebSocket, with every change pushed, is $138.

Endpoints, parameters and errors: REST.

10. Let a coding agent write the Node.js client

Give Claude Code, Codex or Cursor oddspush-agent-guide.md, one file with the whole protocol, two reference clients and an acceptance checklist, and a prompt like:

prompt Text
Read oddspush-agent-guide.md and follow it. Build a Node.js client (ES modules, the ws package) that streams Pinnacle odds for soccer and tennis from OddsPush, keeps a correct price book, and prints every price change in decimal odds. My key is in the ODDSPUSH_KEY environment variable.

11. Full Node.js code: the reference OddsPush client

Everything above in one file: log in, subscribe to soccer on ld and both, keep the book by version and sweep it after each snapshot, ask for a snapshot on missed and on feed "up", watch for silence and reconnect with backoff.

feed.mjs JavaScript
// OddsPush stream client: log in, subscribe, keep a book, reconnect with backoff.
//
//   npm install ws
//   ODDSPUSH_KEY=opk_... node feed.mjs

import WebSocket from 'ws';

const URL = 'wss://feed.oddspush.com/v1/stream';
const KEY = process.env.ODDSPUSH_KEY; // opk_ and 43 characters

const SUBSCRIBE = {
  type: 'subscribe',
  sports: [1],               // soccer
  channels: ['ld', 'both'],  // the displayed price; dz stays out
  snapshot: true,            // the default, spelled out
};

const PRICE = /\/(ld|both)$/;          // the channels of the price book (topic suffix)
const STOP = new Set([4101, 4103, 4107, 4109]); // unauthorized, plan ended, replaced, REST-only plan
const SILENCE_MS = 40_000;  // nothing at all for this long: the connection is dead
const BACKOFF_MIN_MS = 1_000;
const BACKOFF_MAX_MS = 30_000;
const STABLE_MS = 60_000;    // a session this long resets the backoff

// book: event id -> { rec: event fields, markets: Map(key -> market), seen: Map(key -> tick), tick }
const book = new Map();
let tick = 0;            // bumped on every record applied
let sweepFrom = null;    // tick when the snapshot being served was asked for
let sweepNext = null;    // the same, for one asked while another is in flight

function applyRec(rec) {
  tick += 1;
  let ev = book.get(rec.id);
  if (!ev) {
    ev = { rec: {}, markets: new Map(), seen: new Map(), tick: 0 };
    book.set(rec.id, ev);
  }
  ev.tick = tick;
  const { markets, ...fields } = rec;
  Object.assign(ev.rec, fields);
  for (const m of markets || []) {
    const key = m.key;
    if (key === undefined) continue;
    ev.seen.set(key, tick);              // mentioned, even if not newer
    const prev = ev.markets.get(key);
    if (prev && Number.isInteger(m.version) && Number.isInteger(prev.version)
        && m.version <= prev.version) {
      continue;                          // older than what we hold: skip
    }
    if (m.status === 'open') ev.markets.set(key, m);
    else ev.markets.delete(key);         // anything but "open" closes it
  }
  const periods = Array.isArray(rec.periods) ? rec.periods : Object.values(rec.periods || {});
  for (const p of periods) {
    if (p && (p.status === 'closed' || p.status === 'settled')) {
      for (const [key, m] of ev.markets) {
        if (String(m.period) === String(p.period)) ev.markets.delete(key);
      }
    }
  }
}

// A snapshot lists only open markets: a close you missed never shows up in it.
// So note when you ask for one, and on its final part drop what nobody mentioned since.
// Call when you ask for a snapshot of everything you follow: the first subscribe on a
// connection, or a bare snapshot. Not for a later subscribe, nor for a snapshot with its
// own sports or channels: those cover a subset.
function snapshotAsked() {
  if (sweepFrom === null) sweepFrom = tick;
  else if (sweepNext === null) sweepNext = tick; // served after the one in flight
}

// On snapshot_rate_limited. It is about the next request in the order you made them, and it
// can arrive before the final part of the one still being delivered.
function snapshotRefused() {
  if (sweepNext !== null) sweepNext = null;
  else sweepFrom = null;
}

function snapshotDone() {              // on the final part
  const since = sweepFrom;
  sweepFrom = sweepNext;
  sweepNext = null;
  if (since === null) return;
  for (const [id, ev] of book) {
    if (ev.tick <= since) { book.delete(id); continue; } // not in the snapshot, no update since
    for (const key of ev.markets.keys()) {
      if ((ev.seen.get(key) ?? 0) <= since) {            // closed while we were not looking
        ev.markets.delete(key);
        ev.seen.delete(key);
      }
    }
  }
}

function onOdds(data) {
  if (data.type === 'live') {
    const rec = data.rec || {};
    if (data.op === 'del') book.delete(rec.id);
    else if (rec.id !== undefined && PRICE.test(data.topic)) applyRec(rec); // other channels pass any filter
  } else if (data.type === 'snapshot') {
    // A partial state that can arrive at any time: merge, never sweep.
    for (const rec of data.events || []) applyRec(rec);
  }
  // other data.type values: skip
}

let delay = BACKOFF_MIN_MS;
let retryAfterMs = 0;              // from a reconnect_rate_limited error

function connect() {
  let openedAt = 0;
  let silence;
  const ws = new WebSocket(URL, {
    perMessageDeflate: false,
    maxPayload: 16 * 1024 * 1024,
    handshakeTimeout: 10_000,
  });

  const alive = () => {
    clearTimeout(silence);
    silence = setTimeout(() => ws.terminate(), SILENCE_MS);
  };

  const askSnapshot = () => {
    ws.send(JSON.stringify({ type: 'snapshot' }));   // everything you follow
    snapshotAsked();
  };

  ws.on('open', () => {
    openedAt = Date.now();
    sweepFrom = sweepNext = null;       // a snapshot in flight died with the old socket
    ws.send(JSON.stringify({ type: 'login', token: KEY }));
    ws.send(JSON.stringify(SUBSCRIBE)); // no need to wait for welcome
    snapshotAsked();                    // the subscribe brings one
    alive();
  });

  ws.on('message', (raw) => {
    alive();
    const msg = JSON.parse(raw.toString());
    switch (msg.type) {
      case 'odds':
        onOdds(msg.data);
        break;
      case 'snapshot':
        for (const rec of msg.events || []) applyRec(rec);
        if (msg.final) {
          snapshotDone();
          console.info('snapshot done,', book.size, 'events in book');
        }
        break;
      case 'missed':
        console.warn('missed', msg.count, 'updates: asking for a snapshot');
        askSnapshot();
        break;
      case 'feed':
        console.warn('feed', msg.status);
        if (msg.status === 'up') askSnapshot(); // updates were lost while it was down
        break;
      case 'error':
        if (msg.code === 'reconnect_rate_limited') retryAfterMs = (msg.retry_after_s || 0) * 1000;
        else if (msg.code === 'snapshot_rate_limited') snapshotRefused(); // no final part for it
        console.warn('server error:', msg);
        break;
      case 'welcome':
        console.info('logged in, plan', msg.account.plan);
        break;
      // subscription, keepalive, pong, status and any new type: nothing to do
    }
  });

  // DNS, TCP, TLS, a refused handshake. 'close' always follows, and reconnects.
  ws.on('error', (err) => console.warn('connection error:', err.message));

  ws.on('close', (code) => {
    clearTimeout(silence);
    if (STOP.has(code)) {
      console.error('closed with', code, '- not reconnecting');
      process.exitCode = 1;
      return;
    }
    if (openedAt && Date.now() - openedAt > STABLE_MS) delay = BACKOFF_MIN_MS;
    const wait = Math.max(delay / 2 + Math.random() * (delay / 2), retryAfterMs);
    retryAfterMs = 0;
    delay = Math.min(delay * 2, BACKOFF_MAX_MS); // grow from the last delay, capped
    console.info('closed with', code, '- reconnecting in', Math.round(wait), 'ms');
    setTimeout(connect, wait);
  });
}

connect();

Run it against the real feed

The free trial gives you REST and the live stream, all 13 sports, for 7 days. No card.

OddsPush is not affiliated with, endorsed by or sponsored by Pinnacle.