JavaScript Odds API: Resolve Main Lines Correctly in Node.js

JavaScript Odds API - OddsPapi API Blog
How To Guides August 19, 2026

Every odds API tutorial on the internet is written in Python. If you build in JavaScript, you get to translate somebody else’s requests snippet and hope the nesting survives the trip.

This guide skips the translation. It is a full Node.js walkthrough of the OddsPapi API: authentication, fixture discovery, the nested odds payload, line shopping, player props, historical prices, and TypeScript definitions derived from a live response. No SDK, no axios, no dependencies. Node 18 and later ship fetch in the runtime, so the whole tutorial runs on the standard library.

Every number below comes from one live capture: Los Angeles Dodgers v Kansas City Royals, fixture id1300010963302689, pulled at 13:35 UTC on 10 August 2026. That single response carried 18 bookmakers and 6,372 prices.

Why JavaScript devs hit a wall with odds data

Bookmakers do not publish public APIs. DraftKings, Bet365 and Pinnacle all run private endpoints behind rotating tokens and bot detection. The usual JavaScript answer is Puppeteer, and a scraper that renders a sportsbook page in headless Chrome costs you a browser process per book, breaks on every markup change, and gives you one bookmaker at a time.

OddsPapi collapses that into one HTTP GET. One fixture ID returns every book that prices the game, in one JSON document, with decimal, American and fractional odds already converted.

Task Puppeteer / scraping OddsPapi + native fetch
Dependencies Chromium, ~300 MB None. Node 18+ built-in fetch
Books per request 1 18 on the fixture below
Sharp books Blocked or geo-locked Pinnacle, SBOBet, Circa
Prediction markets Separate integration each Kalshi and Polymarket in the same payload
Price formats Parse the DOM string yourself price, priceAmerican, priceFractional
Historical prices Build your own archive /historical-odds, free tier
Breaks when The book ships new CSS It does not

Setup: one file, zero installs

Grab a free API key, then create a project. The .mjs extension gives you ES modules and top level await without a package.json.

node --version    # needs v18 or later
mkdir odds && cd odds
touch lib.mjs

Authentication is a query parameter, not a header. This trips up people who expect Authorization: Bearer. Put the key in the URL and every endpoint works the same way.

// lib.mjs
export const API_KEY = process.env.ODDSPAPI_KEY;
export const BASE = "https://api.oddspapi.io/v4";
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function op(path, params = {}, tries = 3) {
  const url = new URL(`${BASE}/${path}`);
  url.searchParams.set("apiKey", API_KEY);          // query param, NOT a header
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);

  for (let attempt = 0; attempt < tries; attempt++) {
    const res = await fetch(url);
    const body = await res.json();

    if (res.status === 429) {                        // the API tells you how long to wait
      const waitMs = body?.error?.retryMs ?? 2000;
      await sleep(waitMs + 100);
      continue;
    }
    if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(body)}`);
    return body;
  }
  throw new Error(`rate limited on /${path} after ${tries} tries`);
}

That retryMs field matters more than it looks. Read the rate limit section further down before you write any loop.

First call: list the sports

// sports.mjs
import { op } from "./lib.mjs";

const sports = await op("sports");
console.log(`${sports.length} sports`);
console.log(sports.slice(0, 5));
$ ODDSPAPI_KEY=your_key node sports.mjs
69 sports
[
  { sportId: 10, slug: 'soccer', sportName: 'Soccer' },
  { sportId: 11, slug: 'basketball', sportName: 'Basketball' },
  { sportId: 12, slug: 'tennis', sportName: 'Tennis' },
  { sportId: 13, slug: 'baseball', sportName: 'Baseball' },
  { sportId: 14, slug: 'american-football', sportName: 'American Football' }
]

Step 2: find fixtures (and the date window trap)

/fixtures takes a sportId and a from and to date range, up to 10 days apart. The obvious query for today’s games returns almost nothing, and it took a measurement to work out why.

// window.mjs
import { op, sleep } from "./lib.mjs";
const d = (n) => new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);

for (const to of [d(1), d(2), d(3)]) {
  const fx = await op("fixtures", { sportId: 13, from: d(0), to });
  const perDay = {};
  for (const f of fx) {
    const day = f.startTime.slice(0, 10);
    perDay[day] = (perDay[day] ?? 0) + 1;
  }
  console.log(`from ${d(0)} to ${to}:`, JSON.stringify(perDay));
  await sleep(1200);
}
from 2026-08-10 to 2026-08-11: {"2026-08-10":45}
from 2026-08-10 to 2026-08-12: {"2026-08-10":45,"2026-08-11":107,"2026-08-12":2}
from 2026-08-10 to 2026-08-13: {"2026-08-10":45,"2026-08-11":107,"2026-08-12":112,"2026-08-13":2}

Look at the last day in each range. It carries 2 fixtures, and both of those start at exactly 00:00:00Z. So to is a timestamp at midnight UTC of that date, not a whole day. Ask for from=today&to=today and you get the 2 games that started at midnight, which is why a same-day query looks like the sport is dead.

Rule: set to to the day after the last day you want.

// fixtures.mjs
import { op } from "./lib.mjs";
const day = (n) => new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);

const fixtures = await op("fixtures", { sportId: 13, from: day(0), to: day(1) });
const mlb = fixtures.filter((f) => f.tournamentName === "MLB" && f.hasOdds);

for (const f of mlb) {
  console.log(f.fixtureId, f.startTime, `${f.participant1Name} v ${f.participant2Name}`);
}

Two fields do the work here. tournamentName filters the league, because sportId: 13 also returns NPB, KBO, the Mexican League and every minor league affiliate. hasOdds tells you whether /odds will return prices at all: skip anything false and you save a request.

Step 3: the odds payload, and what nobody documents

One /odds call on the Dodgers fixture returned 2.36 MB of JSON: 18 bookmakers, 258 distinct markets, 6,372 individual prices. The nesting runs five levels deep.

bookmakerOdds
  └── "pinnacle"
        ├── bookmakerIsActive: true
        ├── bookmakerFixtureId: "1633332366"
        ├── fixturePath: "https://www.pinnacle.com/en/e/e/e/1633332366"
        ├── suspended: false
        └── markets
              └── "131"                          // market id
                    ├── bookmakerMarketId: "..."
                    ├── marketActive: true
                    └── outcomes
                          └── "131"              // outcome id
                                └── players
                                      └── "0"    // the price object
                                            ├── price: 1.337
                                            ├── priceAmerican: "-297"
                                            ├── active: true
                                            ├── limit: 11127
                                            └── mainLine: true

Three of those book level fields are worth your attention, and none of them appear in a typical tutorial:

  • suspended and bookmakerIsActive flag a book that has pulled its market on this fixture. SBOBet shipped suspended: true on our capture while still returning prices, and its numbers were the worst on the board.
  • fixturePath is a deep link to the book’s own event page. 16 of the 18 books carried one, including https://sportsbook.draftkings.com/event/34500289 and https://polymarket.com/event/mlb-kc-lad-2026-08-10. If you build a comparison site, that is your outbound click.
  • bookmakerFixtureId is the book’s internal ID, useful when you reconcile against another feed.

Parse the moneyline across every book

MLB moneyline is market 131, with outcome 131 for participant 1 and 132 for participant 2. Optional chaining does the defensive work: not every book prices every market.

// board.mjs
import { op } from "./lib.mjs";

const data = await op("odds", { fixtureId: "id1300010963302689" });
const books = data.bookmakerOdds;

const price = (book, marketId, outcomeId) =>
  book.markets?.[marketId]?.outcomes?.[outcomeId]?.players?.["0"];

const board = [];
for (const [slug, book] of Object.entries(books)) {
  const home = price(book, "131", "131");
  const away = price(book, "131", "132");
  if (!home || !away) continue;
  if (home.active === false || away.active === false) continue;   // see the traps section

  board.push({
    slug,
    suspended: book.suspended,
    home: home.price,
    away: away.price,
    vig: +(((1 / home.price + 1 / away.price) - 1) * 100).toFixed(2),
  });
}

board.sort((a, b) => a.vig - b.vig);
console.table(board);
┌─────────┬────────────────────┬───────────┬───────┬───────┬──────┐
│ (index) │ slug               │ suspended │ home  │ away  │ vig  │
├─────────┼────────────────────┼───────────┼───────┼───────┼──────┤
│ 0       │ 'kalshi'           │ false     │ 1.333 │ 3.846 │ 1.02 │
│ 1       │ 'polymarket'       │ false     │ 1.351 │ 3.704 │ 1.02 │
│ 2       │ 'hardrockbet'      │ false     │ 1.333 │ 3.75  │ 1.69 │
│ 3       │ 'pinnacle'         │ false     │ 1.337 │ 3.62  │ 2.42 │
│ 4       │ 'circasports'      │ false     │ 1.333 │ 3.62  │ 2.64 │
│ 5       │ 'caesars'          │ false     │ 1.323 │ 3.65  │ 2.98 │
│ 6       │ 'williamhill'      │ false     │ 1.323 │ 3.65  │ 2.98 │
│ 7       │ 'fanduel'          │ false     │ 1.3   │ 3.65  │ 4.32 │
│ 8       │ 'draftkings'       │ false     │ 1.308 │ 3.56  │ 4.54 │
│ 9       │ 'betmgm'           │ false     │ 1.3   │ 3.6   │ 4.7  │
│ 10      │ 'borgata'          │ false     │ 1.3   │ 3.6   │ 4.7  │
│ 11      │ 'pointsbet.com.au' │ false     │ 1.31  │ 3.5   │ 4.91 │
│ 12      │ 'fourwinds'        │ false     │ 1.3   │ 3.55  │ 5.09 │
│ 13      │ 'bet365'           │ false     │ 1.28  │ 3.6   │ 5.9  │
└─────────┴────────────────────┴───────────┴───────┴───────┴──────┘

Fourteen books, and the margin runs from 1.02% at the prediction markets to 5.9% at Bet365. Same game, same moment.

Dedupe before you average

Two pairs in that table quote byte-identical prices. Caesars and William Hill both sit at 1.323 / 3.65. BetMGM and Borgata both sit at 1.3 / 3.6. They share a pricing engine, so the 14 rows collapse to 12 independent opinions.

const distinct = new Map();
for (const row of board) {
  const key = `${row.home}|${row.away}`;
  distinct.set(key, [...(distinct.get(key) ?? []), row.slug]);
}
for (const [quote, slugs] of distinct)
  if (slugs.length > 1) console.log("identical:", slugs.join(" == "), quote);

// identical: caesars == williamhill 1.323|3.65
// identical: betmgm == borgata 1.3|3.6

If you average raw prices to build a consensus, you double-weight two opinions and call it a crowd. Dedupe on the price tuple first. Our consensus odds guide covers the full weighting method.

Step 4: best price and the fair price

Line shopping in JavaScript is a reduce. Filter suspended books out first.

const live = board.filter((r) => !r.suspended);
const bestHome = live.reduce((m, r) => (r.home > m.home ? r : m));
const bestAway = live.reduce((m, r) => (r.away > m.away ? r : m));

console.log("best Dodgers", bestHome.home, "@", bestHome.slug);
console.log("best Royals ", bestAway.away, "@", bestAway.slug);

// best Dodgers 1.351 @ polymarket
// best Royals  3.846 @ kalshi

Now strip the margin out of Pinnacle’s price to see what those numbers are worth. The power method solves for the exponent k that makes the implied probabilities sum to 1, and bisection converges in a few lines.

function devigPower(prices) {
  const implied = prices.map((p) => 1 / p);
  let lo = 0.5, hi = 2;
  for (let i = 0; i < 200; i++) {
    const k = (lo + hi) / 2;
    const sum = implied.reduce((t, x) => t + x ** k, 0);
    if (sum > 1) lo = k; else hi = k;
  }
  return implied.map((x) => x ** ((lo + hi) / 2));
}

const fair = devigPower([1.337, 3.62]);          // pinnacle
console.log(fair.map((f) => (f * 100).toFixed(1) + "%"));   // [ '73.9%', '26.1%' ]
console.log(fair.map((f) => (1 / f).toFixed(3)));           // [ '1.354', '3.826' ]

Pinnacle’s 2.42% margin hides a fair line of 1.354 / 3.826. The best available prices, 1.351 and 3.846, land either side of it. Shopping 14 books recovered most of the vig on this game and no more than that. Treat “best available price” and “value” as separate claims: the first is measurable, the second needs a model. The no-vig guide compares the proportional, power and Shin methods if you want the maths.

Player props: the players dict is not what you expect

On a game line, players has one key: "0". On a player prop, it is keyed by player ID, and a single outcome holds the whole lineup. Hardcode players["0"] and every prop market reads as empty.

Anytime home run is market 131663, outcome 131664 (“1+”).

// props.mjs
import { op } from "./lib.mjs";

const data = await op("odds", { fixtureId: "id1300010963302689" });
const byPlayer = new Map();

for (const [slug, book] of Object.entries(data.bookmakerOdds)) {
  const outcome = book.markets?.["131663"]?.outcomes?.["131664"];
  if (!outcome) continue;

  for (const [playerId, p] of Object.entries(outcome.players)) {
    if (playerId === "0" || p.active === false) continue;         // "0" is the game-line slot
    const row = byPlayer.get(playerId) ?? { name: p.playerName, quotes: [] };
    row.quotes.push({ slug, price: p.price });
    byPlayer.set(playerId, row);
  }
}

const rows = [...byPlayer.values()].map((r) => {
  const best = r.quotes.reduce((m, q) => (q.price > m.price ? q : m));
  const worst = r.quotes.reduce((m, q) => (q.price < m.price ? q : m));
  return { player: r.name, books: r.quotes.length, best: best.price, at: best.slug,
           worst: worst.price, spread: +(((best.price / worst.price) - 1) * 100).toFixed(1) };
});
console.table(rows.sort((a, b) => a.best - b.best).slice(0, 6));
┌─────────┬──────────────────────┬───────┬──────┬───────────┬───────┬────────┐
│ (index) │ player               │ books │ best │ at        │ worst │ spread │
├─────────┼──────────────────────┼───────┼──────┼───────────┼───────┼────────┤
│ 0       │ 'Ohtani, Shohei'     │ 5     │ 3.45 │ 'bet365'  │ 3.1   │ 11.3   │
│ 1       │ 'Perez, Salvador'    │ 5     │ 5.5  │ 'bet365'  │ 5     │ 10     │
│ 2       │ 'Hernandez, Teoscar' │ 5     │ 5.5  │ 'bet365'  │ 4.9   │ 12.2   │
│ 3       │ 'Pages, Andy'        │ 5     │ 5.5  │ 'bet365'  │ 4.7   │ 17     │
│ 4       │ 'Betts, Mookie'      │ 5     │ 5.6  │ 'fanduel' │ 4.9   │ 14.3   │
│ 5       │ 'Freeman, Freddie'   │ 5     │ 5.75 │ 'bet365'  │ 5.25  │ 9.5    │
│ 6       │ 'Tucker, Kyle'       │ 5     │ 6.25 │ 'bet365'  │ 5.25  │ 19     │
└─────────┴──────────────────────┴───────┴──────┴───────────┴───────┴────────┘

Nineteen players priced by 5 books. Shohei Ohtani to hit a home run ranged from 3.10 to 3.45, an 11.3% spread on the same bet. Note which books show up: props come from US retail. Pinnacle and the exchanges price game lines and skip the lineup. Our MLB player props guide goes through the full batter and pitcher catalogue.

Four JavaScript traps

1. Promise.all will get you rate limited

Fanning out with Promise.all is the idiomatic JavaScript move, and it is exactly wrong here. The API rate limits per endpoint. Six concurrent /odds calls, measured:

Promise.all:            0.68s | 200s: 1 | 429s: 5
sequential + 1s sleep:  9.48s | 200s: 6 | 429s: 0

The 429 body is structured JSON and it tells you the exact wait:

{
  "error": {
    "code": "RATE_LIMITED",
    "details": "Please wait 0.37 seconds before making another request to /v4/odds.",
    "retryAfter": "0.37 seconds",
    "retryMs": 372
  }
}

Two consequences. First, pace same-endpoint calls at roughly one per second and honour retryMs when you miss, which is what the op() helper at the top does. Second, and this bites harder: a 429 body is valid JSON. Code that does const { bookmakerOdds } = await res.json() and checks whether the key exists reads a rate limit error as “this fixture has no odds” and moves on. Check res.status first.

2. JavaScript reorders your market IDs

Market and outcome IDs arrive as integer-like string keys. JavaScript objects sort those numerically, whatever order the JSON used. In the raw response, Pinnacle’s market list starts at "1364". After JSON.parse:

Object.keys(data.bookmakerOdds.pinnacle.markets).slice(0, 8)
// [ '131', '1314', '1316', '1318', '1320', '1322', '1324', '1326' ]

Python keeps insertion order here and JavaScript does not, so a snippet ported from a Python tutorial can order the same payload two ways. Never rely on payload ordering to find the main line. Resolve markets by ID, or by name from /markets, or pick the line the most books quote.

3. active lives on the price, not the outcome

The outcome object has exactly one key: players. Every flag you care about sits one level deeper, on the price object.

const outcome = book.markets["131"].outcomes["131"];
Object.keys(outcome);            // [ 'players' ]  <-- outcome.active does not exist
outcome.players["0"].active;     // true           <-- it lives here

Test for active === false rather than truthiness. The field can arrive as null on some pre-game payloads, and if (!p.active) then discards good prices without a word. On our capture, 433 of 6,372 prices were active: false, which is the suspended alt-line ladder, and the other 5,939 were live.

4. The API allows browser calls, which is the problem

The API returns access-control-allow-origin: *, so a fetch from the browser works. Nothing stops you shipping your key to the client, and nothing stops a visitor reading it out of the network tab and burning your quota.

Proxy it. This is the whole server, using only node:http, with a 10 second cache that also keeps you under the rate limit when several users load the same fixture:

// proxy.mjs
import { createServer } from "node:http";

const API_KEY = process.env.ODDSPAPI_KEY;      // never leaves the server
const BASE = "https://api.oddspapi.io/v4";
const cache = new Map();
const TTL_MS = 10_000;

createServer(async (req, res) => {
  const { pathname, searchParams } = new URL(req.url, "http://localhost");
  if (pathname !== "/odds") return res.writeHead(404).end();

  const fixtureId = searchParams.get("fixtureId");
  const hit = cache.get(fixtureId);
  if (hit && Date.now() - hit.at < TTL_MS) {
    res.writeHead(200, { "content-type": "application/json", "x-cache": "hit" });
    return res.end(hit.body);
  }

  const upstream = await fetch(`${BASE}/odds?apiKey=${API_KEY}&fixtureId=${fixtureId}`);
  const body = JSON.stringify(await upstream.json());
  cache.set(fixtureId, { at: Date.now(), body });

  res.writeHead(upstream.status, { "content-type": "application/json", "x-cache": "miss" });
  res.end(body);
}).listen(8787);
$ curl -sD- -o /dev/null "localhost:8787/odds?fixtureId=id1300010963302689" | grep x-cache
x-cache: miss
$ curl -sD- -o /dev/null "localhost:8787/odds?fixtureId=id1300010963302689" | grep x-cache
x-cache: hit

Your front end calls /odds on your own origin. The key stays in the environment.

TypeScript definitions

These types are derived from a live response, not from documentation. The script walked all 6,372 prices and recorded the type each field takes, so the nullable unions below match what the feed ships.

// oddspapi.d.ts
export interface ExchangeLevel {
  cents: number;
  price: number;
  size: number;
  limit: number;
}

export interface ExchangeMeta {
  back?: ExchangeLevel[];
  lay?: ExchangeLevel[];
  bookmakerLayOutcomeId?: string;
}

export interface Price {
  active: boolean;
  betslip: string | null;
  bookmakerOutcomeId: string;
  bookmakerChangedAt: string | null;   // when the book moved it
  changedAt: string;                   // when OddsPapi saw the move
  limit: number | null;                // Pinnacle and exchanges only
  playerName: string | null;           // "Last, First" on props
  price: number;                       // decimal
  priceAmerican: string;               // string, not a number
  priceFractional: string;
  mainLine: boolean;
  exchangeMeta: ExchangeMeta | null;
}

export interface Outcome {
  players: Record<string, Price>;      // "0" on game lines, player id on props
}

export interface Market {
  bookmakerMarketId: string;
  marketActive: boolean;
  outcomes: Record<string, Outcome>;
}

export interface BookmakerOdds {
  bookmakerIsActive: boolean;
  bookmakerFixtureId: string;
  fixturePath: string;                 // deep link to the book's event page
  suspended: boolean;
  markets: Record<string, Market>;
}

export interface OddsResponse {
  fixtureId: string;
  participant1Id: number;
  participant2Id: number;
  sportId: number;
  tournamentId: number;
  seasonId: number;
  statusId: number;
  hasOdds: boolean;
  startTime: string;
  trueStartTime: string | null;
  trueEndTime: string | null;
  updatedAt: string;
  bookmakerOdds: Record<string, BookmakerOdds>;
}

Two notes. priceAmerican and priceFractional are strings, so cast before arithmetic. exchangeMeta is null on sportsbooks and a depth-of-book ladder on Kalshi, Polymarket and Betfair, and its shape varies between exchange slugs, so narrow it before you read back[0].

Resolving market names

The /markets catalogue is a lookup table, not a per-sport list. It returned 32,815 rows in about one second, and it is global: the same rows come back whatever sportId you pass. Read the market IDs off a live /odds payload, then use the catalogue to name them.

// names.mjs
import { op } from "./lib.mjs";

const catalogue = await op("markets", { sportId: 13 });
const marketName = new Map(catalogue.map((m) => [String(m.marketId), m.marketName]));
const handicap = new Map(catalogue.map((m) => [String(m.marketId), m.handicap]));

const data = await op("odds", { fixtureId: "id1300010963302689" });
const bookCount = {};
for (const book of Object.values(data.bookmakerOdds))
  for (const id of Object.keys(book.markets)) bookCount[id] = (bookCount[id] ?? 0) + 1;

for (const [id, n] of Object.entries(bookCount).sort((a, b) => b[1] - a[1]).slice(0, 6))
  console.log(`${n} books | ${id} | ${marketName.get(id)} | line ${handicap.get(id)}`);
15 books | 131    | Winner (incl. extra innings)                   | line 0
15 books | 1322   | Over Under (incl. extra innings)               | line 7.5
15 books | 13806  | Over Under First Inning                        | line 0.5
13 books | 1326   | Over Under (incl. extra innings)               | line 8.5
13 books | 13100  | First Inning Result                            | line 0
13 books | 131621 | Over Under Strikeouts (incl. extra innings)    | line 4.5

Every total line has its own market ID: 7.5 runs is 1322, 8.5 runs is 1326. There is no single "totals" market to hardcode. Sorting by book count is how you find the line the market agrees on.

Historical odds, free

The same fixture ID works against /historical-odds, which returns the full price history rather than the current price. Two differences to code around: the top-level key is bookmakers rather than bookmakerOdds, and players["0"] is an array of snapshots. Maximum 3 bookmakers per call.

// history.mjs
import { op } from "./lib.mjs";

const hist = await op("historical-odds", {
  fixtureId: "id1300010963302689",
  bookmakers: "pinnacle,draftkings,fanduel",       // max 3
});

for (const [slug, book] of Object.entries(hist.bookmakers)) {
  const snaps = book.markets["131"]?.outcomes["131"]?.players["0"];
  if (!Array.isArray(snaps)) continue;

  let changes = 0;
  for (let i = 1; i < snaps.length; i++)
    if (snaps[i].price !== snaps[i - 1].price) changes++;

  console.log(slug.padEnd(12),
    "snapshots", String(snaps.length).padStart(4),
    "| changes", String(changes).padStart(3),
    "| open", snaps[0].price, "-> latest", snaps.at(-1).price,
    "| limit", snaps[0].limit, "->", snaps.at(-1).limit);
}
draftkings   snapshots    7 | changes   6 | open 1.339 -> latest 1.308 | limit null -> null
pinnacle     snapshots   37 | changes  23 | open 1.348 -> latest 1.337 | limit 5387 -> 11127
fanduel      snapshots    4 | changes   1 | open 1.32  -> latest 1.3   | limit null -> null

Pinnacle repriced the Dodgers 23 times while FanDuel moved once. The limit column is the other half of the story: Pinnacle's maximum doubled from 5,387 to 11,127 as the game approached, which is the book telling you how much it now trusts its own number. Retail books ship null because their limits are per-account.

Count price changes, not snapshots. The feed records on a cadence and most snapshots repeat the previous price. To store this as a time series, our odds database walkthrough covers change-only inserts, and the WebSocket feed pushes updates instead of making you poll.

Putting it together

The full scanner, top to bottom: pull today's board, fetch each fixture at a safe pace, and print the biggest gap between the best and worst price on the moneyline.

// scan.mjs
import { op, sleep } from "./lib.mjs";

const day = (n) => new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);
const fixtures = await op("fixtures", { sportId: 13, from: day(0), to: day(1) });
const games = fixtures.filter((f) => f.tournamentName === "MLB" && f.hasOdds);

for (const game of games) {
  const data = await op("odds", { fixtureId: game.fixtureId });
  const quotes = [];

  for (const [slug, book] of Object.entries(data.bookmakerOdds ?? {})) {
    if (book.suspended) continue;
    const home = book.markets?.["131"]?.outcomes?.["131"]?.players?.["0"];
    const away = book.markets?.["131"]?.outcomes?.["132"]?.players?.["0"];
    if (!home || !away || home.active === false || away.active === false) continue;
    quotes.push({ slug, home: home.price, away: away.price });
  }
  if (quotes.length < 2) continue;

  const hi = Math.max(...quotes.map((q) => q.home));
  const lo = Math.min(...quotes.map((q) => q.home));
  console.log(
    `${game.participant1Name} v ${game.participant2Name}`.padEnd(42),
    `${quotes.length} books | spread ${(((hi / lo) - 1) * 100).toFixed(2)}%`
  );

  await sleep(1000);        // one call per second per endpoint
}
games: 4
Toronto Blue Jays v Boston Red Sox          14 books | spread 6.38%
Atlanta Braves v New York Mets              13 books | spread 6.48%
Minnesota Twins v Baltimore Orioles         14 books | spread 7.37%
St. Louis Cardinals v Philadelphia Phillies 14 books | spread 9.06%

Between 6% and 9% separates the best and worst moneyline on the same team, across four games on one afternoon. That gap is the reason line shopping exists.

That is the pattern every tool on this blog is built on. Swap sportId for soccer or the NFL, swap the market ID, and the shape holds. If you want the same walkthrough in Python, start with your first odds API call, then line shopping across 350+ books.

FAQ

Is there an official JavaScript SDK for OddsPapi?

You do not need one. Every endpoint is a GET with an apiKey query parameter, and Node 18 and later include fetch. The op() helper in this guide is 20 lines and handles rate limit retries, which is the only thing an SDK would add.

Can I call the odds API directly from the browser?

The API sends access-control-allow-origin: *, so the request succeeds. Do not do it in production: your API key ends up in the client bundle and in the network tab. Run the 30 line Node proxy from this guide and call your own origin instead.

Why does my fixtures query return almost nothing?

The to parameter is a midnight UTC boundary, not a whole day. from=2026-08-10&to=2026-08-10 returns only fixtures starting at exactly 00:00Z. Set to to the day after the last day you want.

Why is my player prop parser returning empty?

On game lines the players dict has a single "0" key. On player props it is keyed by player ID, and each entry carries a playerName like "Ohtani, Shohei". Iterate the dict and skip "0" rather than hardcoding it.

How fast can I poll?

Roughly one request per second per endpoint on the free tier. Concurrency does not help: six parallel /odds calls returned five 429s in our test while the sequential version returned six clean responses. Honour retryMs from the error body, and use the WebSocket feed if you need push updates rather than polling.

Get your key

The free tier covers everything in this guide: 350+ bookmakers in the catalogue, 69 sports, player props, and the full historical price archive that other providers put behind a paid plan. No card, no sales call.

Grab a free API key and run the first script in this guide in the next five minutes.