On this page
  1. Design: three tables, one cursor
  2. Schema
  3. The poller
  4. Scheduling against the rate limit
  5. Cursor aging and resync
  6. Querying closing-line value
  7. Running it and next steps

Every serious odds project ends up wanting history: what the price was when you looked, what it opened at, and what it closed at. This recipe is a Node.js poller that keeps a SQLite copy of the Pinnacle prematch board via the pinnodds REST API, records every price change, and answers the closing-line-value question with plain SQL. It uses the built-in node:sqlite module, so there are no native dependencies to compile. The API calls come from the Node quickstart; if you have not read the section on since deltas there, do that first.

Design: three tables, one cursor

The poller keeps events (one row per fixture), prices (the current price for each selection) and price_history (an append-only row whenever a price differs from the stored one). A fourth table holds the since cursor per sport so a restart resumes instead of re-downloading. Separating current from history keeps the hot query fast and the history table honest: it only grows when something moved.

The board is prematch only. Live prices change many times a second and SQLite on a single writer will keep up, but the volume and the poll cadence make the raw WebSocket the better source for live history. Prematch through REST is exactly the workload since was designed for.

Schema

Field names follow the API. starts is stored as the ISO string the API returns, and time comparisons convert it with strftime. Spread points are from the home side, as the API quotes them, so a row with side = 'away' and points = -0.5 means the away price against a home −0.5 line.

schema.sql
-- schema.sql
PRAGMA journal_mode = WAL;

CREATE TABLE IF NOT EXISTS events (
  event_id    INTEGER PRIMARY KEY,
  sport_id    INTEGER NOT NULL,
  league_id   INTEGER,
  league_name TEXT,
  home        TEXT NOT NULL,
  away        TEXT NOT NULL,
  starts      TEXT NOT NULL,            -- ISO 8601 UTC from the API
  event_type  TEXT NOT NULL,            -- 'prematch' | 'live'
  last        INTEGER NOT NULL,         -- per-event cursor from the API
  updated_at  INTEGER NOT NULL          -- unix ms, our clock
);
CREATE INDEX IF NOT EXISTS events_starts ON events (starts);

-- current price per selection; one row per (event, period, market, side, points)
CREATE TABLE IF NOT EXISTS prices (
  event_id  INTEGER NOT NULL REFERENCES events (event_id) ON DELETE CASCADE,
  period    INTEGER NOT NULL,           -- 0 = full match
  market    TEXT    NOT NULL,           -- money_line | spreads | totals | team_total
  side      TEXT    NOT NULL,           -- home | away | draw | over | under
  points    REAL,                       -- spread (home side) or total line, NULL for money_line
  price     REAL    NOT NULL,           -- decimal
  seen_at   INTEGER NOT NULL,
  PRIMARY KEY (event_id, period, market, side, points)
);

-- append-only: a row every time a price differs from the stored one
CREATE TABLE IF NOT EXISTS price_history (
  id        INTEGER PRIMARY KEY,
  event_id  INTEGER NOT NULL,
  period    INTEGER NOT NULL,
  market    TEXT    NOT NULL,
  side      TEXT    NOT NULL,
  points    REAL,
  price     REAL    NOT NULL,
  seen_at   INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS hist_sel ON price_history (event_id, period, market, side, points, seen_at);

CREATE TABLE IF NOT EXISTS cursors (
  sport_id INTEGER PRIMARY KEY,
  last     INTEGER NOT NULL,
  full_at  INTEGER NOT NULL             -- when we last took a full snapshot
);

The primary key on prices uses points, which is NULL for money lines. SQLite treats NULLs as distinct in a primary key, so the lookup uses IS rather than =. The ON DELETE CASCADE is only active if you enable foreign keys with PRAGMA foreign_keys = ON; the poller never deletes events, so leaving it off is fine.

The poller

The whole program is one file. It takes a full snapshot when it has no cursor or the cursor is old, otherwise a delta, then walks each event's periods, flattens money line, spreads and totals into selections, and writes a history row when the price differs.

poller.mjs
// poller.mjs  -  Node >= 22.13 (node:sqlite), pinnodds SDK
import { DatabaseSync } from "node:sqlite";
import { readFileSync } from "node:fs";
import { setTimeout as sleep } from "node:timers/promises";
import { Client, SPORTS, AuthError, RateLimitError, PinnoddsError } from "pinnodds";

const api = new Client(process.env.PINNODDS_KEY);
const db = new DatabaseSync(process.env.DB_PATH ?? "./odds.db");
db.exec(readFileSync(new URL("./schema.sql", import.meta.url), "utf8"));

const SPORT_IDS = (process.env.SPORT_IDS ?? String(SPORTS.soccer)).split(",").map(Number);
const PLAN = process.env.PLAN ?? "trial";                 // trial | pro | scale
const RESYNC_AFTER_MS = 30 * 60 * 1000;                   // full snapshot at least every 30 min

// Budget: stay well under the plan's published limit.
// Trial 20/min & 100/day, Pro 10 req/s, Scale 30 req/s.
const INTERVAL_MS = { trial: 15 * 60 * 1000, pro: 5_000, scale: 2_000 }[PLAN];

const upEvent = db.prepare(`
  INSERT INTO events (event_id, sport_id, league_id, league_name, home, away, starts, event_type, last, updated_at)
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  ON CONFLICT(event_id) DO UPDATE SET league_name=excluded.league_name, starts=excluded.starts,
    event_type=excluded.event_type, last=excluded.last, updated_at=excluded.updated_at`);
const getPrice = db.prepare(`SELECT price FROM prices WHERE event_id=? AND period=? AND market=? AND side=? AND points IS ?`);
const upPrice = db.prepare(`
  INSERT INTO prices (event_id, period, market, side, points, price, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)
  ON CONFLICT(event_id, period, market, side, points) DO UPDATE SET price=excluded.price, seen_at=excluded.seen_at`);
const addHist = db.prepare(`INSERT INTO price_history (event_id, period, market, side, points, price, seen_at) VALUES (?, ?, ?, ?, ?, ?, ?)`);
const getCursor = db.prepare(`SELECT last, full_at FROM cursors WHERE sport_id=?`);
const setCursor = db.prepare(`INSERT INTO cursors (sport_id, last, full_at) VALUES (?, ?, ?)
  ON CONFLICT(sport_id) DO UPDATE SET last=excluded.last, full_at=excluded.full_at`);

// Flatten one period object into (market, side, points, price) rows.
// money_line: { home, away, draw? }. spreads: { "<hdp>": { hdp, home, away, max } } (home side).
// totals: { "<points>": { points, over, under, max } }.
function* selections(period) {
  const ml = period.money_line ?? {};
  for (const side of ["home", "draw", "away"]) if (ml[side] != null) yield ["money_line", side, null, ml[side]];
  for (const s of Object.values(period.spreads ?? {})) {
    if (s.home != null) yield ["spreads", "home", s.hdp, s.home];
    if (s.away != null) yield ["spreads", "away", s.hdp, s.away];
  }
  for (const t of Object.values(period.totals ?? {})) {
    if (t.over  != null) yield ["totals", "over",  t.points, t.over];
    if (t.under != null) yield ["totals", "under", t.points, t.under];
  }
}

// node:sqlite has no transaction helper, so BEGIN/COMMIT are explicit.
function apply(board, sportId, now) {
  db.exec("BEGIN");
  try {
    let changed = 0;
    for (const ev of board.events) {
      upEvent.run(ev.event_id, ev.sport_id, ev.league_id ?? null, ev.league_name ?? null, ev.home, ev.away,
                  ev.starts, ev.event_type, ev.last, now);
      for (const [key, period] of Object.entries(ev.periods ?? {})) {
        const pnum = period.number ?? Number(key.replace("num_", ""));
        for (const [market, side, points, price] of selections(period)) {
          const cur = getPrice.get(ev.event_id, pnum, market, side, points);
          if (cur && cur.price === price) continue;
          upPrice.run(ev.event_id, pnum, market, side, points, price, now);
          addHist.run(ev.event_id, pnum, market, side, points, price, now);
          changed++;
        }
      }
    }
    db.exec("COMMIT");
    return changed;
  } catch (e) { db.exec("ROLLBACK"); throw e; }
}

async function pollSport(sportId) {
  const now = Date.now();
  const c = getCursor.get(sportId);
  const full = !c || now - c.full_at > RESYNC_AFTER_MS;      // cursors age fast: resync periodically
  const board = await api.markets({ sportId, eventType: "prematch", ...(full ? {} : { since: c.last }) });
  const changed = apply(board, sportId, now);
  setCursor.run(sportId, board.last, full ? now : c.full_at);
  console.log(new Date(now).toISOString(), `sport ${sportId} ${full ? "FULL" : "delta"}: ${board.events.length} events, ${changed} price changes, cursor ${board.last}`);
}

for (;;) {
  for (const sportId of SPORT_IDS) {
    try {
      await pollSport(sportId);
    } catch (err) {
      if (err instanceof AuthError) { console.error("bad key"); process.exit(1); }
      if (err instanceof RateLimitError) { console.warn("429, sleeping", err.retryAfter, "s"); await sleep(err.retryAfter * 1000); continue; }
      if (err instanceof PinnoddsError) { console.warn("api error:", err.message); continue; }
      throw err;
    }
  }
  await sleep(INTERVAL_MS);
}
Output
2026-09-28T09:00:03.412Z sport 1 FULL: 612 events, 9114 price changes, cursor 297592
2026-09-28T09:15:03.877Z sport 1 delta: 41 events, 133 price changes, cursor 297911
2026-09-28T09:30:04.102Z sport 1 delta: 38 events, 97 price changes, cursor 298230

The flattening in selections is the one place you should check against a real response before trusting it. A money line is an object keyed by side (home, away, and draw where the sport has one). Spreads are an object keyed by handicap, each entry holding hdp, home, away and max; totals are keyed by line with points, over, under and max. The pinnodds docs print the full market-type table and an example event. Team totals (team_total for the primary line, team_totals for every alternate line per side) are omitted here; add a branch the same way. node:sqlite exposes no transaction helper yet, so the code uses explicit BEGIN and COMMIT; one transaction per poll keeps a full snapshot of several thousand rows to a fraction of a second.

Scheduling against the rate limit

Rate limits are per key and set by plan: Trial allows 20 requests a minute and 100 a day, Pro 10 requests a second, Scale 30. One poll per sport is one request, so the interval times the number of sports must fit the daily budget on Trial and the per-second budget on Pro and Scale. The INTERVAL_MS table in the script encodes this.

Scroll for more

Poll interval and daily request count by pinnodds plan and number of sports
PlanLimitSportsIntervalRequests/day
Trial20/min, 100/day115 min96
Trial20/min, 100/day230 min96
Pro10 req/s35 s51,840
Scale30 req/s132 s561,600

On Trial, 96 requests a day leaves four for debugging; do not run the poller and your own curl experiments on the same key. Trial is fine for validating the schema over a weekend. For anything you want to analyse later, Pro at a 5-second interval captures nearly every prematch move, and Scale exists for people polling all thirteen sports every two seconds. The pricing breakdown compares the tiers; note that they differ only in rate limit and push access, not in which markets you get.

Cursor aging and resync

Each response carries a top-level last; pass it back as since to receive only events whose own last is newer. The catch is that cursors age fast: a since value more than a few minutes old matches most of the board anyway, so a Trial poller on a 15-minute interval is effectively taking snapshots regardless. That is fine, the code path is the same, but do not expect the small deltas you see at 5 seconds.

Two further reasons to resync on a timer, which the script does every 30 minutes via RESYNC_AFTER_MS. First, an event that Pinnacle removes will not appear in a delta, so the events table only learns it is gone when a full snapshot omits it; extend the script to mark missing events if that matters to you. Second, every REST response carries an X-Data-Status header (fresh, stale, unknown); when upstream is behind, the API returns an empty events array rather than old prices. The SDK does not expose that header, so an empty delta on a Saturday afternoon should trigger a check of api.health(), or switch the fetch to raw fetch() as the Node quickstart shows and read the header directly.

Querying closing-line value

Closing-line value compares the price you took with the price just before kickoff. With price_history in place, it is one query: the first price we saw, the last price before your bet time, and the last price before starts.

clv.sql
-- Opening price, price at a chosen time, and closing price for one selection.
-- "Closing" = last price we saw before kickoff. Requires the poller to run up to kickoff.
WITH sel AS (
  SELECT h.*, e.starts
  FROM price_history h JOIN events e USING (event_id)
  WHERE h.event_id = :event_id AND h.period = 0 AND h.market = 'money_line' AND h.side = 'home'
    AND h.seen_at <= strftime('%s', e.starts) * 1000
)
SELECT
  (SELECT price FROM sel ORDER BY seen_at ASC  LIMIT 1) AS opening,
  (SELECT price FROM sel WHERE seen_at <= :bet_time ORDER BY seen_at DESC LIMIT 1) AS at_bet,
  (SELECT price FROM sel ORDER BY seen_at DESC LIMIT 1) AS closing;

-- CLV in implied-probability points for a bet taken at price p_bet:
-- clv_pp = (1/closing - 1/p_bet) * 100   -> positive means you beat the close
Output
opening  at_bet  closing
2.10     2.05    1.92
-- bet at 2.05, closed 1.92: (1/1.92 - 1/2.05) * 100 = +3.30 pp

Measure CLV in implied-probability points rather than as a percentage of the price, or short favourites will look like they never move. The same table answers other questions cheaply: how often a market moves more than 5% in the last hour, which leagues drift most, whether your drop alerts preceded the close or chased it. pinnodds has a plain-language CLV guide and a note on why Pinnacle's close is the usual benchmark.

Running it and next steps

Node 22.13 or newer is required for node:sqlite without a flag. Set the plan and sports in the environment and start it; the database file appears next to the script.

shell
npm init -y && npm pkg set type=module && npm install pinnodds
export PINNODDS_KEY=...  PLAN=trial  SPORT_IDS=1
node poller.mjs

# inspect
sqlite3 odds.db "SELECT count(*) FROM events; SELECT count(*) FROM price_history;"

For production, run it under systemd exactly as the Telegram recipe does, with Restart=always and the key in a root-owned environment file. Because this poller uses REST only, it does not compete for the single SSE connection, so it can share a key with an alert bot as long as the combined request rate fits the plan.

  • Add drops. Call api.drops({ sportId, minDropPct: 2 }) on the same schedule and store the rows; market_key gives you a stable join to prices. See the SSE guide for the two drop shapes.
  • Add specials. Pass includeSpecials to prematchFixtures and store player and team props with their parent_id.
  • Go live. For in-play history, feed the WebSocket frames into the same price_history table; the merge rules there tell you when a market closes.