On this page
  1. Install
  2. First request: the live board
  3. Prematch fixtures, markets and lines
  4. Deltas with since
  5. Odds-drop alerts
  6. Errors and rate limits
  7. Raw fetch() without the SDK
  8. Next steps

Pinnacle closed its public API on 23 July 2025. The old Node wrappers on npm still build, but they authenticate with Basic auth against endpoints that now require a funded account and written approval. This page uses the pinnodds feed instead: Pinnacle's live and prematch markets over REST, SSE and WebSocket, with an official Node client that ships its own TypeScript types. Every snippet is a complete ES module you can paste into a .mts file and run. The same walkthrough exists for Python, and the alternatives comparison covers other feeds if you are still choosing.

Install

The SDK has zero runtime dependencies and uses the global fetch, so Node 18 or newer is required. Types are bundled; there is no separate @types package. Read the key from the environment. It is issued at signup with no card and no Pinnacle account.

terminal
npm install pinnodds          # zero runtime deps, ships .d.ts
export PINNODDS_KEY=your_key_here
# package.json: { "type": "module" }  or use .mjs / .mts files

Trial keys cover every REST call below. The SSE stream in the drops section needs a plan with streaming enabled (Stream, Pro + SSE, Scale, or the 3-day full demo).

First request: the live board

One call returns every live event for a sport with all markets across all periods. SPORTS maps names to the integer ids the API uses. The response object has sport_id, sport_name, a last cursor and an events array; field names are snake_case exactly as the API returns them.

live-board.mts
import { Client, SPORTS } from "pinnodds";

const api = new Client(process.env.PINNODDS_KEY!);

const board = await api.markets({ sportId: SPORTS.tennis, eventType: "live" });
console.log(board.events.length, "live tennis events, cursor", board.last);

const ev = board.events[0];
if (ev) {
  console.log(ev.event_id, ev.league_name, "-", ev.home, "v", ev.away);
  console.log("moneyline:", ev.periods.num_0.money_line);   // period 0 = full match
}
Output
6 live tennis events, cursor 297592
1628594960 ATP Madrid - Carlos Alcaraz v Jannik Sinner
moneyline: { home: 2.45, away: 1.6 }

An event has event_id, league_id, league_name, starts (ISO 8601 UTC), home, away, event_type and a periods object keyed num_0, num_1 and onward. Period 0 is the full match. Each period holds money_line, spreads, totals and team_total. Prices are decimal and spreads are quoted from the home side.

Prematch fixtures, markets and lines

Prematch is split into three calls of decreasing size. prematchFixtures returns the whole board for a sport in the same envelope as the live board. prematchMarkets returns one event with every period. prematchLines returns just the active prices for one event, flattened, with an optional market-type filter.

prematch.mts
import { Client, SPORTS } from "pinnodds";

const api = new Client(process.env.PINNODDS_KEY!);

const fixtures = await api.prematchFixtures({ sportId: SPORTS.soccer });
const ev = fixtures.events[0];
console.log(ev.starts, ev.home, "v", ev.away);

// Every period and market for that one event
const full = await api.prematchMarkets(ev.event_id);
console.log(Object.keys(full.periods));            // [ 'num_0', 'num_1', ... ]

// Compact: only active totals lines, no period nesting
const lines = await api.prematchLines(ev.event_id, { marketType: "totals" });
console.log(lines);
Output
2026-09-29T19:00:00Z Arsenal v Chelsea
[ 'num_0', 'num_1', 'num_2' ]
{ ... totals lines for event 1631005165, decimal prices ... }

marketType accepts money_line, spreads, totals or team_total. Add includeSpecials: true to prematchFixtures or markets to receive player props, team props and exact scores as extra rows, each with a parent_id that points at the parent match. Quarter lines and specials come with every plan; plans differ in rate limit and push access, not data depth.

Deltas with since

Keep the last value from each response and send it back as since. The API then returns only events whose own last is newer than your cursor. Merge those into a Map keyed by event_id; the returned events are whole objects, not patches.

deltas.mts
import { Client, SPORTS, RateLimitError } from "pinnodds";
import { setTimeout as sleep } from "node:timers/promises";

const api = new Client(process.env.PINNODDS_KEY!);
const sportId = SPORTS.soccer;

const snap = await api.markets({ sportId, eventType: "prematch" });
const board = new Map(snap.events.map((e) => [e.event_id, e]));
let cursor = snap.last;
console.log("snapshot:", board.size, "events");

for (;;) {
  await sleep(30_000);                                   // Trial: 20/min, 100/day
  try {
    const delta = await api.markets({ sportId, eventType: "prematch", since: cursor });
    for (const e of delta.events) board.set(e.event_id, e);   // full objects, replace
    cursor = delta.last;                                 // feed back next call
    console.log(`${delta.events.length} changed, cursor -> ${cursor}`);
  } catch (err) {
    if (err instanceof RateLimitError) { await sleep(err.retryAfter * 1000); continue; }
    throw err;
  }
}

Cursors age quickly. A since value more than a few minutes old matches most of the board, so after a restart or a long pause take a fresh snapshot rather than resuming an old cursor. The 30-second interval above respects the trial cap of 20 requests a minute but burns the 100-a-day allowance in under an hour; a production poller sits on Pro (10 requests a second) or Scale (30). The plan breakdown lists every tier, and the SQLite recipe turns this loop into a persistent price-history store.

Odds-drop alerts

The feed detects price falls server-side and pushes them over SSE. streamDrops wraps that stream as an async iterator: it connects, reconnects on failure, and yields one alert object at a time. Pass an AbortSignal to stop it.

drops-sse.mts
import { Client } from "pinnodds";

const api = new Client(process.env.PINNODDS_KEY!);
const ac = new AbortController();
process.on("SIGINT", () => ac.abort());                 // Ctrl-C ends the loop cleanly

// SSE push. Async iterator, reconnects on its own.
for await (const d of api.streamDrops({ minDrop: 5, signal: ac.signal })) {
  const pct = (1 - d.to_price / d.from_price) * 100;
  console.log(
    `${d.home} v ${d.away}  ${d.sect} ${d.outcome} p${d.period}  ` +
    `${d.from_price} -> ${d.to_price}  (${pct.toFixed(1)}%)  nvp ${d.nvp}`,
  );
}
console.log("stream closed");
Output
Sunshine Coast Phoenix v Cairns Dolphins  Moneyline Home p4  2.86 -> 2.7  (5.6%)  nvp 3.04
Bentleigh Greens v St Albans Saints  Spread Home p0  2.37 -> 2.25  (5.1%)  nvp 2.31

Options: mode selects "live" or "prematch", minDrop is the percentage threshold (the server default is 5, the floor is 1), and recheck, prematch only, holds each alert for that many seconds and re-verifies the price before emitting it, which suppresses bounces.

drops-prematch.mts
// Prematch only, and hold each alert 30 s to confirm the price did not bounce back
for await (const d of api.streamDrops({ mode: "prematch", minDrop: 3, recheck: 30 })) {
  console.log(d.league, d.home, "v", d.away, d.sect, d.outcome, d.from_price, "->", d.to_price);
}

Alert fields: id (event id), sport, sport_id, league, home, away, sect (Moneyline, Spread, Total, TeamTotal), outcome (Home, Away, Over, Under and similar), period, point, from_price, to_price, nvp (no-vig price of the new odds), limit, price_x, price_y, price_z, and a Unix starts. There is no percentage field; derive it from the two prices as above.

The pull-style alternative is the REST drops buffer, which also works on a trial key:

drops-rest.mts
import { Client, SPORTS } from "pinnodds";

const api = new Client(process.env.PINNODDS_KEY!);

// Pull-style buffer of recent drops; works on a trial key
const res = await api.drops({ sportId: SPORTS.soccer, minDropPct: 3 });
for (const row of res.drops) {
  console.log(row.ts, row.home, "v", row.away, row.market, row.designation, row.points,
              row.from, "->", row.to, `${row.drop_pct}%`, row.market_key);
}

REST rows are shaped differently: market, designation, points, from, to, a precomputed drop_pct, a stable market_key and is_live. Keep two small mappers rather than one that guesses. The SSE guide covers raw connections, backfill from the buffer and the one-connection-per-key rule.

Errors and rate limits

The SDK throws three classes you can test with instanceof. AuthError means the key is missing or unknown. RateLimitError exposes retryAfter in seconds. PinnoddsError is the base class for anything else the API or transport reports.

errors.mts
import { Client, SPORTS, AuthError, RateLimitError, PinnoddsError } from "pinnodds";
import { setTimeout as sleep } from "node:timers/promises";

const api = new Client(process.env.PINNODDS_KEY ?? "");

async function fetchBoard(sportId: number) {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      return await api.markets({ sportId });
    } catch (err) {
      if (err instanceof AuthError) {
        console.error("bad or missing key: check PINNODDS_KEY");
        process.exit(1);
      }
      if (err instanceof RateLimitError) {
        await sleep(err.retryAfter * 1000);           // seconds until the window resets
        continue;
      }
      if (err instanceof PinnoddsError) {
        console.error("api error:", err.message);
        await sleep(2 ** attempt * 1000);
        continue;
      }
      throw err;                                      // not ours: bug, DNS, etc.
    }
  }
  throw new Error("gave up after 3 attempts");
}

const board = await fetchBoard(SPORTS.soccer);
console.log(board.events.length, "events");

Limits are per key and set by plan, not by IP: Trial 20 a minute, 100 an hour and 100 a day; Pro 10 requests a second; Scale 30 requests a second. Hitting 429 and retrying early only lengthens the wait, so always sleep for retryAfter.

Raw fetch() without the SDK

The REST API is plain JSON with an x-api-key header, so a twenty-line helper around the global fetch is enough. Doing it yourself also exposes the freshness headers the SDK hides.

raw-fetch.mts
const KEY = process.env.PINNODDS_KEY!;
const BASE = "https://pinnodds.com";

async function get<T>(path: string, params: Record<string, string | number>): Promise<T> {
  const url = new URL(path, BASE);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
  const r = await fetch(url, { headers: { "x-api-key": KEY }, signal: AbortSignal.timeout(15_000) });
  if (r.status === 401) throw new Error("invalid key");
  if (r.status === 429) {
    const body = await r.json().catch(() => ({}));
    throw new Error(`rate limited (${body.window}), retry in ${body.retry_after_ms} ms`);
  }
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  console.log("data status:", r.headers.get("X-Data-Status"), "age ms:", r.headers.get("X-Data-Age-Ms"));
  return r.json() as Promise<T>;
}

interface Board { sport_id: number; sport_name: string; last: number; events: Array<{ event_id: number; home: string; away: string }> }

const board = await get<Board>("/kit/v1/markets", { sport_id: 1, event_type: "live" });
console.log(board.sport_name, board.events.length, "events");

const drops = await get<{ total: number }>("/api/drops", { mode: "prematch", min_drop_pct: 2, max_age_sec: 3600 });
console.log(drops.total, "prematch drops in the last hour");

Every REST response carries X-Data-Status (fresh, stale or unknown), X-Data-Age-Ms and X-Data-Board. When upstream falls behind, the API withholds the board and returns the ordinary envelope with an empty events array rather than old prices, so an empty response with stale is a signal, not a bug. A 429 body contains error, window, limit and retry_after_ms. The endpoint list is /kit/v1/markets, /kit/v1/details, /kit/v1/prematch/fixtures, /kit/v1/prematch/markets, /kit/v1/prematch/lines and /api/drops, all documented in the pinnodds reference. If you are porting code from the old Pinnacle API, the endpoint mapping saves time.

Next steps

Snapshots, deltas and alerts cover most odds tooling. Three directions from here:

  • Every frame, not just drops. The raw WebSocket feed forwards each upstream update so you keep your own live book with the ws package.
  • Alerts that reach a phone. The Telegram alert recipe adds filtering, dedupe and a systemd unit.
  • History for closing-line value. The SQLite recipe uses node:sqlite to keep every price change; pinnodds explains the analysis in its CLV guide.