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?

Pinnacle pre-match odds by API

Last updated

Pinnacle's pre-match odds come on the same OddsPush WebSocket as the live ones, on the pre channel: subscribe with "channels": ["pre"] and you get a snapshot of the upcoming matches in the sports you ask for, then each price change as Pinnacle publishes it, with its version and timestamp. Over REST, list events with /v1/events?channel=pre and read one with /v1/events/{event_id}?channel=pre. Keep those prices in a book of their own, record every version to follow the line from its first price to kick-off, and compare it with the closing line OddsPush keeps when the match starts.

This guide covers where the pre-match prices are, how much of the board they cover, how to subscribe without downloading more than you need, what happens at kick-off, and a short Node.js program that prints every line move and, when the match starts, the first price it saw next to the close.

1. Where Pinnacle's pre-match odds live: the pre channel

Every update belongs to one channel, the last segment of its topic. Live prices come on ld and both (matchups/reg/sp/29/live/ld); pre-match prices come on pre (matchups/reg/sp/29/pre). Both travel on the one stream, so a single connection and a single key give you the whole board, before and during the match.

  • Same message, same fields. A pre-match update is an odds message like any other: the event in rec, its open markets with key, version, status and prices in American odds, and ts, when Pinnacle published it, in Unix milliseconds.
  • Alternate lines included. Alternate spreads and totals come alongside the main line, each flagged with isAlternate (false for the main line).
  • All 13 sports, every plan. Pre-match is part of the feed, not an add-on: the free trial, REST and REST + WebSocket all include it.

The channel list and what each one carries are in Channels.

2. What the pre-match feed covers

A measurement on November 6, 2026 at 12:30 UTC: 2,983 events with 76,622 pre-match markets across 12 sports (baseball was out of season). The median event had 29 markets, alternate lines included. Against the number of matchups Pinnacle itself listed per sport at the same moment:

Sport Events with pre-match prices Matchups Pinnacle listed
Soccer 1,323 1,400
Basketball 446 471
Hockey 131 209
American football 70 109
MMA, boxing All All

The numbers move with the calendar: a Saturday in the football season holds more than a quiet weekday. Read them as the shape of the feed on that date, not as a promise. Current sports and markets are on coverage.

3. Subscribe to Pinnacle pre-match odds on the stream

After the login (the WebSocket guide walks through it), ask for the sports you want on pre:

subscribe JSON
{"type": "subscribe", "sports": [1], "channels": ["pre"]}

The subscribe brings a snapshot of the current pre-match book, in parts of up to 100 events or about 256 KB, then every change as it happens. A pre-match event priced only on pre is left out of any snapshot that does not ask for pre, so the channel has to be in the request.

If the same connection also follows live prices, keep the two snapshots apart. A snapshot merges every channel it covers into one markets list, so subscribe to pre without one and ask for it on its own, the same pattern the docs give for dz:

live and pre-match on one connection JSON
{"type": "subscribe", "sports": [1], "channels": ["ld", "both"]}
{"type": "subscribe", "sports": [1], "channels": ["pre"], "snapshot": false}
{"type": "snapshot", "sports": [1], "channels": ["pre"]}

With pre and the live channels in one filter, never send a bare snapshot: ask for each set with its channels. Details in snapshot.

4. Keep pre-match prices in a book of their own

Route each update by the last segment of its topic, and keep pre apart from the ld and both book. Three reasons:

  • Pre-match prices freeze at kick-off but keep arriving. Folded into the live book, a frozen pre-match price looks current.
  • REST keeps them apart too. merged in /v1/events/{event_id} holds only ld and both unless you ask for channel=pre.
  • They answer different questions. The pre-match book says what the line is now and how it got there; the live book says what the price is in play.

Within the pre book the rules are the usual ones: key events by rec.id and markets by key, apply a market only when its version is higher, drop it when its status is not "open" or its period closes, and handle op: "del" before looking at the channel: a removal can come on any channel and removes the whole event. The full recipe is in Keeping a correct book.

5. Track line movement from opening to close

OddsPush gives you the current pre-match book and every change from the moment you are connected, and after the start it gives you the closing line. It does not serve earlier versions, so the history of a line is yours to record. Each market's version only goes up and each update carries Pinnacle's ts, so that history is simple to keep:

  • Store every version with its ts: event id, market key, version, timestamp and prices, one row per change. A table that only grows is enough.
  • The opening price is the first one you saw. A market key you have never seen, in an update after your snapshot, is a line that just appeared: a new matchup or a new alternate line. A market first seen in a snapshot was already open, so its price there is not its opening.
  • Compare like with like. Same key, same line: s;0;ou;2.5 against s;0;ou;2.5, not against a main total that has moved to 2.75.
  • Measure in probability, without the margin. A move in odds reads more clearly as a change in fair probability. See Pinnacle no-vig odds and the odds converter.

The end of the line is the close. With "closing": true in your subscribe, a closing message arrives as each match you follow starts, with every open pre-match market and its no-vig and fair prices. That is the benchmark for your closing line value.

6. Node.js: print every pre-match line move

One file and the ws package (npm install ws, Node.js 18 or newer). It subscribes to soccer on pre with closing on, keeps the pre-match book by version with the history of each market, prints every price change and every new line with its time, and when a match starts prints the first price it recorded for each main line next to the close and the fair close.

prematch.mjs JavaScript
// Pinnacle pre-match odds: soccer on the pre channel, every line move, first price vs close.
//
//   npm install ws
//   ODDSPUSH_KEY=opk_... node prematch.mjs

import WebSocket from 'ws';

const URL = 'wss://feed.oddspush.com/v1/stream';
const KEY = process.env.ODDSPUSH_KEY;
const SUBSCRIBE = { type: 'subscribe', sports: [1], channels: ['pre'], closing: true }; // brings a snapshot
const STOP = new Set([4101, 4103, 4107, 4109]); // unauthorized, plan ended, replaced, REST-only plan

// The pre-match book, apart from any live one:
// event id -> { name, markets: Map(key -> { version, period, prices, history: [{ ts, version, prices }] }) }
const book = new Map();

const decimal = (a) => (a > 0 ? 1 + a / 100 : 1 + 100 / -a);
const show = (prices = []) =>
  prices
    .map((p) => p.designation + (p.points === undefined ? '' : ' ' + p.points) + ' ' + decimal(p.price).toFixed(3))
    .join('  ');

// ts: when Pinnacle published the update; null for a snapshot (the current state, not a move).
function applyPre(rec, ts) {
  let ev = book.get(rec.id);
  if (!ev) book.set(rec.id, (ev = { name: '', markets: new Map() }));
  if (rec.participants) ev.name = rec.participants.map((p) => p.name).join(' v ');
  for (const m of rec.markets || []) {
    if (m.key === undefined || !Number.isInteger(m.version)) continue;
    const prev = ev.markets.get(m.key);
    if (prev && m.version <= prev.version) continue; // older than what we hold
    if (m.status !== 'open') {
      ev.markets.delete(m.key);
      continue;
    }
    const history = prev ? prev.history : [];
    history.push({ ts, version: m.version, prices: m.prices });
    ev.markets.set(m.key, { version: m.version, period: m.period, prices: m.prices, history });
    if (ts === null) continue; // snapshot: the state now, not a move
    if (!prev) console.log(new Date(ts).toISOString(), rec.id, ev.name, m.key, 'NEW', show(m.prices));
    else if (show(prev.prices) !== show(m.prices)) {
      console.log(new Date(ts).toISOString(), rec.id, ev.name, m.key, show(prev.prices), '->', show(m.prices));
    }
  }
  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);
    }
  }
}

// At kick-off: the first price you recorded against Pinnacle's close, main lines only.
function onClosing(c) {
  const ev = book.get(c.event_id); // the pre-match event id
  console.log('CLOSE', c.event_id, c.home, 'v', c.away, '(' + c.reason + ')');
  for (const m of c.markets) {
    const first = ev?.markets.get(m.key)?.history[0];
    if (!first || m.isAlternate) continue;
    const fair = m.prices.map((p) => p.fair_decimal.toFixed(3)).join(' ');
    console.log('  ', m.key, '| first', show(first.prices), '| close', show(m.prices), '| fair', fair);
  }
}

function connect() {
  const ws = new WebSocket(URL, { perMessageDeflate: false, maxPayload: 16 * 1024 * 1024 });
  const snapshot = () => ws.send(JSON.stringify({ type: 'snapshot' })); // only pre is in the filter

  ws.on('open', () => {
    ws.send(JSON.stringify({ type: 'login', token: KEY }));
    ws.send(JSON.stringify(SUBSCRIBE));
  });

  ws.on('message', (raw) => {
    const msg = JSON.parse(raw.toString());
    if (msg.type === 'odds' && msg.data.type === 'live') {
      const { rec = {}, topic = '', op, ts } = msg.data;
      if (op === 'del') book.delete(rec.id); // the event is gone, whatever channel it came on
      else if (topic.split('/').pop() === 'pre') applyPre(rec, ts);
      // data.type "snapshot" (a partial state with no channel): skip; the snapshot after feed up covers it
    } else if (msg.type === 'snapshot') {
      for (const rec of msg.events || []) applyPre(rec, null);
      if (msg.final) console.info('pre-match book:', book.size, 'events');
    } else if (msg.type === 'closing') {
      onClosing(msg.data);
    } else if (msg.type === 'missed' || (msg.type === 'feed' && msg.status === 'up')) {
      snapshot();
    } else if (msg.type === 'error') {
      console.warn('server error:', msg);
    }
  });

  ws.on('error', (err) => console.warn('connection error:', err.message));
  ws.on('close', (code) => {
    if (STOP.has(code)) return console.error('closed with', code, '- not reconnecting');
    setTimeout(connect, 5_000); // use real backoff in production
  });
}

connect();
  • Partial states are skipped. An odds message whose data.type is "snapshot" has no channel to route by; the snapshot the program asks for after feed "up" covers what it held.
  • History lives in memory. For real use, write each change to a database instead of the history array.
  • Production needs more. Backoff with jitter, a silence watchdog and the sweep after each snapshot are in the Node.js guide and its twin, Pinnacle odds in Python.

7. What happens to pre-match odds at kick-off

Measured over 45 minutes on November 6, 2026, with every channel open:

  • The live match is another event. Its rec.parentId is the pre-match event id, and its markets use the same keys (s;0;m, s;0;ou;2.5), so a live price compares directly with the pre-match one.
  • Pre-match prices freeze. After the start, the pre-match record keeps arriving on pre through the match, with its prices frozen. Of 25 matches that started in that window, none had a pre-match price change after its close.
  • The removal comes at the end. The del on pre arrives when the match is over, not at kick-off.
  • The scheduled time is a guide. A live event can exist as pending before the start, and in tennis startTime is approximate: one match in the sample started 33 minutes late. /v1/membership lists the pre-match events about to go live.

OddsPush takes the closing line when the match really starts: when its live event turns started, when the pre-match record does, when the event is removed after its start, or, with no live event, at least 30 minutes after the scheduled start once the pre-match prices have not moved for 10 minutes. It is kept for 7 days, on the stream (closing) and over REST (/v1/closing/{event_id}, by the pre-match or the live event id).

8. Pinnacle pre-match odds over REST

Same key, in the x-token header. /v1/events?channel=pre lists every event with the number of open markets per channel; the rows with pre above zero are the ones with pre-match prices. Matches already under way are among them, with their pre-match prices frozen: check startTime in the event's meta.

shell Shell
curl -s -H "x-token: $ODDSPUSH_KEY" "https://feed.oddspush.com/v1/events?sport_id=1&channel=pre"

For one event, add channel=pre and merged becomes the pre-match book. That matters most for fair prices: without channel=pre, merged holds only ld and both, so a match that has not kicked off comes back with an empty fair.

shell Shell
curl -s -H "x-token: $ODDSPUSH_KEY" "https://feed.oddspush.com/v1/events/1638780901?channel=pre&fair=true"

In Python, with the standard library only:

prematch_rest.py Python
"""Pinnacle pre-match odds over REST: soccer events priced on the pre channel."""
import json
import os
import urllib.request
from datetime import datetime, timezone

BASE = "https://feed.oddspush.com/v1"
KEY = os.environ["ODDSPUSH_KEY"]


def get(path: str) -> dict:
    req = urllib.request.Request(BASE + path, headers={"x-token": KEY})
    with urllib.request.urlopen(req, timeout=20) as r:  # a 429 raises HTTPError: wait retry-after
        return json.loads(r.read())


def decimal(american: int) -> float:
    return 1 + american / 100 if american > 0 else 1 + 100 / -american


index = get("/events?sport_id=1&channel=pre")  # soccer; "markets" counts the pre ones
prematch = [row for row in index["events"] if row["pre"] > 0]
print(len(prematch), "soccer events with pre-match markets (matches under way included)")

now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")  # startTime is ISO 8601 UTC
for row in prematch[:5]:
    ev = get(f"/events/{row['event_id']}?channel=pre&fair=true")  # merged = the pre book
    meta = ev["meta"]
    teams = " v ".join(p.get("name", "?") for p in meta.get("participants", []))
    started = (meta.get("startTime") or "9") <= now  # started: pre-match prices are frozen
    print(row["event_id"], meta.get("startTime"), teams, "-", row["pre"], "markets",
          "(started, frozen)" if started else "")
    ml = ev["merged"].get("s;0;m")  # full-time moneyline
    fair = {p["designation"]: p["fair_decimal"] for p in ev.get("fair", {}).get("s;0;m", {}).get("prices", [])}
    for p in (ml or {}).get("prices", []):
        f = fair.get(p["designation"])
        print(f"    {p['designation']:<5} {decimal(p['price']):.3f}   fair {f:.3f}" if f else
              f"    {p['designation']:<5} {decimal(p['price']):.3f}")
  • Rate limits per account: up to 120 requests at once, then 300 a minute. A 429 carries retry-after in seconds.
  • One IP at a time. A request from a new IP takes over the key and closes a stream open from the old one.
  • REST or the stream. Polling gives you the line as it is when you ask; to record every version you need the stream. REST is $69 a month, REST + WebSocket $138.

Endpoints and parameters: /v1/events and /v1/events/{event_id}.

Line moves feed straight into value betting and into comparing Pinnacle with prediction markets in Polymarket and Kalshi vs Pinnacle.

Pre-match and live, from one key

The free trial gives you REST and the live stream, pre-match and live for all 13 sports, with no-vig, fair and closing odds, for 7 days. No card.

OddsPush is not affiliated with, endorsed by or sponsored by Pinnacle. This page explains how to read and record prices; it is not betting or investment advice.