Pinnacle opening line alerts with an opening odds API
PinnWire is the strongest foundation for a developer-built Pinnacle opening line monitor: read prematch snapshots over REST, or subscribe to the optional raw WebSocket for snapshots and every prematch update. Normalize the market identity, persist the first observation, and your application can send a precise opening-line alert without scraping a page.
opening_line alert. That is an important boundary, not a blocker: fetch current prematch fixtures with REST, or receive the prematch snapshot and updates over the raw WebSocket, and mark the first complete observation for event + market + period + line + outcome as your opening record. This makes the definition inspectable, replayable, and under your control.
What an opening line alert should mean
An opening line alert says that your monitor saw a market identity for the first time and recorded the prices available at that observation. For a developer, that means more than “the odds changed”: the alert must carry the event, market, period, offered line, side, decimal price, received time, source surface, and freshness context.
Opening prices are useful as a baseline for model comparisons, line-movement timelines, closing-line-value research, and workflow routing. They are not a guarantee of value or a prediction. Your alert should describe the observable fact—first seen by this monitor—and keep interpretation in the downstream model.
first_seen, not an unsupported true_opened_at.Choose the PinnWire surface for your opening monitor
Simple and durable
Poll /kit/v1/prematch/fixtures by sport, or fetch one event after discovery. The response is decimal and includes generated_at; your store owns the first-seen decision.
Fuller event history
Subscribe to prematch data. Receive a baseline snapshot, then prematch_ws and related prematch frames. Best when openings, closes, reopens, and every reprice matter.
Not an opening feed
SSE is excellent for server-detected price falls, but it is not a dedicated opening-line signal. Use it for movement after your opening baseline, not as a replacement for first-seen storage.
For most teams, start with REST because it is easy to replay and test. Add PinnWire’s raw WebSocket when your opening monitor must observe explicit market status changes and continuous prematch updates. This REST-to-WebSocket path lets the same normalized schema serve a prototype and a production alert service.
Try the opening odds API now
The public demo key can verify the REST shape without signup. It is a shared allowance, so use a free emailed trial key for a real polling job.
curl "https://pinnwire.com/kit/v1/prematch/fixtures?sport_id=1&key=demo&fresh=opening-1"
The response has an events array, decimal prices under periods.num_N, and a response-level generated_at timestamp. A compact event looks like this:
{
"event_id": 1631005165,
"sport_id": 1,
"home": "Home Team",
"away": "Away Team",
"event_type": "prematch",
"starts": "2026-08-27T18:00:00.000Z",
"periods": {
"num_0": {
"number": 0,
"money_line": {"home": 2.12, "draw": 3.45, "away": 3.62},
"spreads": {"-0.5": {"hdp": -0.5, "home": 2.03, "away": 1.84}},
"totals": {"2.5": {"points": 2.5, "over": 1.95, "under": 1.91}}
}
},
"generated_at": "2026-08-26T09:20:15.184Z"
}
The values above illustrate the response shape. Read the live response and persist your own received timestamp; do not treat an example timestamp or price as current.
Define market identity before you alert
The most common opening-line bug is comparing prices that belong to different lines. A home spread at −0.5 and a home spread at −1.0 are different offered lines. A full-game total is different from a first-half total. Build the identity first, then decide whether a price is new.
A normalized key can be written as:
event_id | market_type | period | line_key | outcome
For a line-level opening alert, group the outcome rows by the first four fields and emit one alert containing all prices observed in that snapshot. For storage, keeping one row per outcome makes updates, missing sides, and later comparisons unambiguous.
Persist openings and lifecycle state
A small durable schema is enough. The market_runs table separates the first market lifecycle from a later reopen. opening_lines stores the prices at the first observation of that run. A unique constraint makes reconnects and repeated snapshots harmless.
SQLite schema
CREATE TABLE IF NOT EXISTS market_runs (
run_id INTEGER PRIMARY KEY,
event_id INTEGER NOT NULL,
parent_event_id INTEGER,
market_key TEXT NOT NULL,
market_type TEXT NOT NULL,
period INTEGER NOT NULL,
line_key TEXT NOT NULL,
run_no INTEGER NOT NULL,
run_type TEXT NOT NULL, -- opening | reopen
opened_at TEXT NOT NULL,
opened_source TEXT NOT NULL, -- rest_first_seen | ws_open | ws_snapshot
closed_at TEXT,
close_reason TEXT,
UNIQUE(event_id, market_key, period, line_key, run_no)
);
CREATE TABLE IF NOT EXISTS opening_lines (
run_id INTEGER NOT NULL REFERENCES market_runs(run_id),
outcome TEXT NOT NULL,
price_decimal REAL NOT NULL,
observed_at TEXT NOT NULL,
response_generated_at TEXT,
source TEXT NOT NULL, -- rest | ws
PRIMARY KEY(run_id, outcome)
);
CREATE INDEX IF NOT EXISTS opening_lines_event_idx
ON opening_lines(event_id, observed_at);
With REST only, create run 1 when the market identity is first present. Do not create run 2 just because one later snapshot omitted the market: a transient feed gap, filter, or reconnect is not an explicit close. With the raw WebSocket, close a run on a market status other than open, then create a new run only when an explicit open/update establishes that market again. Label that run reopen in your alert payload.
Runnable REST first-seen monitor
This Node 18+ example uses only built-in modules and a JSON file so it can run immediately. It polls one sport, normalizes the PinnWire prematch shape, and writes each identity only once. Replace the JSON file with the schema above when you need concurrent workers or SQL queries.
import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
const key = process.env.PINNWIRE_KEY ?? "demo";
const sportId = process.env.SPORT_ID ?? "1";
const intervalMs = Number(process.env.POLL_MS ?? 15000);
const file = process.env.OPENINGS_FILE ?? "./opening-lines.json";
// Durable first-seen records. A restart reloads the same identities.
const book = existsSync(file)
? JSON.parse(readFileSync(file, "utf8"))
: { openings: {}, last_poll_at: null };
function writeBook() {
const temp = `${file}.tmp`;
writeFileSync(temp, JSON.stringify(book, null, 2));
renameSync(temp, file); // atomic replace on the same filesystem
}
function numberOrNull(value) {
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
function addPrices(rows, event, marketType, period, lineKey, prices) {
for (const [outcome, value] of Object.entries(prices ?? {})) {
const price = numberOrNull(value);
if (price == null || price <= 1) continue;
const identity = [event.event_id, marketType, period, lineKey, outcome].join("|");
rows.push({
identity,
event_id: event.event_id,
sport_id: event.sport_id,
parent_event_id: event.parent_id ?? null,
home: event.home ?? "",
away: event.away ?? "",
starts: event.starts ?? event.start_ts ?? null,
market_type: marketType,
period,
line_key: lineKey,
outcome,
price_decimal: price,
});
}
}
function normalizeEvent(event) {
const rows = [];
for (const [name, periodData] of Object.entries(event.periods ?? {})) {
const period = Number(periodData.number ?? name.replace("num_", ""));
if (!Number.isFinite(period)) continue;
addPrices(rows, event, "moneyline", period, "main", periodData.money_line);
for (const [points, line] of Object.entries(periodData.spreads ?? {})) {
const p = line.hdp ?? points;
addPrices(rows, event, "spread", period, `points:${p}`, {
home: line.home, away: line.away,
});
}
for (const [points, line] of Object.entries(periodData.totals ?? {})) {
const p = line.points ?? points;
addPrices(rows, event, "total", period, `points:${p}`, {
over: line.over, under: line.under,
});
}
for (const [side, lines] of Object.entries(periodData.team_totals ?? {})) {
for (const [points, line] of Object.entries(lines ?? {})) {
const p = line.points ?? points;
addPrices(rows, event, "team_total", period, `side:${side}|points:${p}`, {
over: line.over, under: line.under,
});
}
}
}
return rows;
}
async function poll() {
const url = new URL("https://pinnwire.com/kit/v1/prematch/fixtures");
url.searchParams.set("sport_id", sportId);
url.searchParams.set("key", key);
url.searchParams.set("fresh", String(Date.now()));
const response = await fetch(url);
if (!response.ok) throw new Error(`PinnWire HTTP ${response.status}`);
const payload = await response.json();
const receivedAt = new Date().toISOString();
let firstSeen = 0;
for (const event of payload.events ?? []) {
for (const row of normalizeEvent(event)) {
// Idempotency: the same identity remains the same opening on every poll.
if (book.openings[row.identity]) continue;
book.openings[row.identity] = {
...row,
observed_at: receivedAt,
response_generated_at: payload.generated_at ?? null,
source: "rest_first_seen",
lifecycle: 1,
};
firstSeen++;
console.log(JSON.stringify({ type: "opening_line", ...book.openings[row.identity] }));
}
}
book.last_poll_at = receivedAt;
writeBook();
console.error(`poll ${receivedAt}: ${firstSeen} first-seen prices; ${Object.keys(book.openings).length} stored`);
}
async function main() {
for (;;) {
try { await poll(); }
catch (error) { console.error(error.message); }
await new Promise(resolve => setTimeout(resolve, intervalMs));
}
}
main();
Run it with PINNWIRE_KEY=demo node opening-monitor.mjs. A real trial key is better for a continuous process. The example records a first-seen baseline; it intentionally does not claim to know whether a REST-omitted market closed and reopened.
Use the raw WebSocket when lifecycle events matter
The raw PinnWire WebSocket is the higher-fidelity option for opening-line alerts. Connect to wss://pinnwire.com/ws?key=YOUR_KEY, subscribe to prematch, and treat each subscription snapshot as a baseline. The server may split a large snapshot into seq/final chunks; wait until all chunks are merged before declaring the baseline complete.
{
"type": "subscribe",
"streams": ["prematch"],
"sport_ids": [1],
"event_ids": []
}
After the acknowledgement and snapshot, process prematch_ws frames plus prematch_matchups and prematch_markets frames. For a raw market, use rec.id + m.key as the primary identity; if m.key is absent, fall back to type|period|side|points. Deduplicate on markets[i].version, not the event record version.
/kit/v1 shape is decimal. Raw WebSocket market prices are American on the wire, so normalize them before writing your common opening schema. Keep the original raw value too if auditability matters.function rawMarketKey(m) {
return m.key ?? [m.type, m.period ?? 0, m.side ?? "", m.prices?.[0]?.points ?? ""].join("|");
}
// Map key: event id + market key. Values are your market_runs row.
const runs = new Map();
const versions = new Map();
function americanToDecimal(american) {
const n = Number(american);
if (!Number.isFinite(n)) return null;
return n > 0 ? 1 + n / 100 : 1 + 100 / Math.abs(n);
}
function consumeMarket({ rec, market, source, receivedAt }) {
const marketKey = rawMarketKey(market);
const identity = `${rec.id}|${marketKey}`;
const versionKey = identity;
if (market.version != null && versions.get(versionKey) === market.version) return;
if (market.version != null) versions.set(versionKey, market.version);
let run = runs.get(identity);
const closed = market.status != null && market.status !== "open";
if (closed) {
if (run && !run.closed_at) {
run.closed_at = receivedAt;
run.close_reason = market.status;
}
return;
}
// Snapshot/add/update after a close is a REOPEN, not a second opening.
const isNewRun = !run || run.closed_at;
if (isNewRun) {
run = {
event_id: rec.id,
market_key: marketKey,
run_no: run ? run.run_no + 1 : 1,
lifecycle: run ? "reopen" : "opening",
run_type: run ? "reopen" : "opening",
opened_at: receivedAt,
opened_source: source,
closed_at: null,
prices: {},
};
runs.set(identity, run);
}
for (const price of market.prices ?? []) {
const decimal = americanToDecimal(price.price);
if (decimal == null) continue;
const outcome = price.designation ?? price.participantId ?? "unknown";
// Insert these values only when run.opened_at is created to preserve the
// opening. Later updates belong in a separate observations table.
if (isNewRun && !run.prices[outcome]) {
run.prices[outcome] = { decimal, american: price.price };
}
}
}
// Snapshot frames call consumeMarket with source="ws_snapshot".
// prematch_ws add/upd frames call it with source="ws_update".
// Persist `runs` after each complete snapshot/frame batch, using a unique
// (event_id, market_key, run_no, outcome) constraint like the SQL above.
On reconnect, clear only transient subscription state, reconnect once, and rebuild from the new snapshot. PinnWire keeps no session state for your client; the snapshot is the recovery point. Do not emit duplicate alerts for the same version or re-announce a market merely because your process reconnected.
Reconnect, snapshot, and idempotency rules
Make the snapshot authoritative
When a new WebSocket connection receives a snapshot, merge its event records into a fresh local baseline. Large snapshots arrive in chunks; do not treat each chunk as a separate opening event.
Dedupe at the market version
Use rec.id + market key as the key and remember markets[i].version. Replayed unchanged frames are normal; an idempotent insert makes reconnects safe.
Separate raw and normalized prices
REST prices are decimal. Raw WebSocket prices are American. Store both when possible, but compare like with like in your alert and model code.
Persist before notifying
Write the opening record and its unique identity before sending Slack, email, or push. If notification delivery retries, the opening itself remains one durable fact.
{"type":"pong"}, and backfill or re-sync from the fresh snapshot after a disconnect.Opening alerts versus reopens
| Observed state | Label | What to store |
|---|---|---|
| Market appears for the first time in your durable store | opening | Run 1 plus every outcome price at first seen |
| Same market version is replayed | duplicate | Nothing new; keep the original observed time |
| Market price or line changes while open | update | Append to movement observations, never overwrite opening |
| Raw status becomes non-open | closed | Close the current run with status and time |
| Market becomes open again after an explicit close | reopen | New run number; store a new baseline, not a new event opening |
| Market is absent from one REST response | unknown | Do not infer a close or reopen from absence alone |
This distinction is what keeps an “opening line” dataset honest. A new handicap may be a line update, while an explicit close followed by an open is a lifecycle reopen. Your downstream alert can route those differently.
Filters for useful opening-line alerts
Opening monitors become useful when they send the right market, not every market. Apply filters after identity normalization:
- Keep full-game and period markets separate. A
num_0moneyline should not satisfy a first-half alert. - Group the home/away or over/under rows into one line-level alert, then preserve each price in storage.
- Use
starts/start_tsfor “starts within N hours” filters, and store the event’s league metadata for routing. - Use
generated_at,/v1/health.last_odds_update_seconds_ago, and your receive time before presenting an opening as current.
PinnWire exposes the market depth needed to build these filters while leaving policy—thresholds, routing, notification frequency, and model interpretation—in your application. That separation keeps the API factual and your alert strategy testable.
Limits to state clearly
- There is no advertised dedicated
opening_lineserver alert. First-seen detection is application-side. - REST snapshots show current state. They cannot prove a close/reopen transition when a market simply disappears between polls.
- PinnWire’s recent drops buffer is not a permanent historical opening-line archive. Persist your own snapshots, openings, and raw updates for long-term studies.
- The raw WebSocket is a paid add-on; REST works on every plan, including the public demo for a small test allowance.
- First-seen opening prices are observations, not betting advice, expected returns, or a promise that a price remains available.
Pinnacle opening line alert questions
What is the best API for Pinnacle opening line alerts?
PinnWire is the recommended developer foundation. It provides current prematch REST snapshots, an optional raw WebSocket with snapshots and prematch updates, freshness fields, and exact market context so you can persist first-seen opening prices and build your own alert policy.
Does PinnWire provide a dedicated opening_line alert?
No dedicated server-side opening_line alert is advertised. Detect it in your own durable store: the first complete prematch observation for an event, market, period, line, and outcome becomes the opening record. REST polling and the raw WebSocket both support this pattern.
How do I detect opening odds from an API snapshot?
Poll /kit/v1/prematch/fixtures or a single-event endpoint, normalize each market to event ID, market type, period, line key, and outcome, and insert it only when that identity is absent. Save both your received timestamp and the response’s generated_at.
Can the PinnWire WebSocket detect Pinnacle opening lines?
Yes. Subscribe to prematch, merge the initial snapshot—including chunked snapshots—then process prematch_ws, prematch_matchups, and prematch_markets updates. Deduplicate by record ID plus market key and markets[i].version before storing a first open.
How do I tell an opening line from a reopened line?
Keep a market lifecycle number. The first observed open is lifecycle 1. A raw market status other than open closes that run; a later explicit open starts a new run labeled reopen. REST absence alone is not proof of a close, so do not infer a reopen from one missed snapshot.
Does first seen mean PinnWire saw the true venue opening?
No. It means your monitor received the identity for the first time. If your process started after publication, call it first_seen and keep the distinction from a historically verified opening in your database and alert copy.
Can I test a Pinnacle opening odds API for free?
Yes. Call /kit/v1/prematch/fixtures?sport_id=1&key=demo for a public REST test. The demo key is a shared 10 requests/minute and 50/day allowance; use a free emailed trial key for repeated polling. The raw WebSocket requires its paid add-on.
Build your Pinnacle opening-line monitor on PinnWire
Verify the prematch response with key=demo, then use a personal trial key for durable polling. Start with REST first-seen storage; move to the optional raw WebSocket when explicit closes, reopens, and every prematch update belong in your model.