Pinnacle pre-match odds by API
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
oddsmessage like any other: the event inrec, its open markets withkey,version,statusandpricesin American odds, andts, when Pinnacle published it, in Unix milliseconds. -
Alternate lines included. Alternate spreads and totals come alongside the main line, each
flagged with
isAlternate(falsefor 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:
{"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:
{"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.
mergedin/v1/events/{event_id}holds onlyldandbothunless you ask forchannel=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.5againsts;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.
// 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
oddsmessage whosedata.typeis"snapshot"has no channel to route by; the snapshot the program asks for afterfeed"up"covers what it held. -
History lives in memory. For real use, write each change to a database instead of the
historyarray. - 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.parentIdis 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
prethrough 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
delonprearrives when the match is over, not at kick-off. -
The scheduled time is a guide. A live event can exist as
pendingbefore the start, and in tennisstartTimeis approximate: one match in the sample started 33 minutes late./v1/membershiplists 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.
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.
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:
"""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
429carriesretry-afterin 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.