{"id":3651,"date":"2026-08-19T10:00:00","date_gmt":"2026-08-19T10:00:00","guid":{"rendered":"https:\/\/oddspapi.io\/blog\/?p=3651"},"modified":"2026-09-07T14:01:51","modified_gmt":"2026-09-07T14:01:51","slug":"javascript-odds-api-nodejs","status":"publish","type":"post","link":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/","title":{"rendered":"JavaScript Odds API: Resolve Main Lines Correctly in Node.js"},"content":{"rendered":"<p>Every odds API tutorial on the internet is written in Python. If you build in JavaScript, you get to translate somebody else&#8217;s <code>requests<\/code> snippet and hope the nesting survives the trip.<\/p>\n<p>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 <code>axios<\/code>, no dependencies. Node 18 and later ship <code>fetch<\/code> in the runtime, so the whole tutorial runs on the standard library.<\/p>\n<p>Every number below comes from one live capture: <strong>Los Angeles Dodgers v Kansas City Royals<\/strong>, fixture <code>id1300010963302689<\/code>, pulled at 13:35 UTC on 10 August 2026. That single response carried <strong>18 bookmakers and 6,372 prices<\/strong>.<\/p>\n<h2>Why JavaScript devs hit a wall with odds data<\/h2>\n<p>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.<\/p>\n<p>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.<\/p>\n<figure class=\"wp-block-table\">\n<table>\n<thead>\n<tr>\n<th>Task<\/th>\n<th>Puppeteer \/ scraping<\/th>\n<th>OddsPapi + native fetch<\/th>\n<\/tr>\n<\/thead>\n<tbody>\n<tr>\n<td>Dependencies<\/td>\n<td>Chromium, ~300 MB<\/td>\n<td>None. Node 18+ built-in <code>fetch<\/code><\/td>\n<\/tr>\n<tr>\n<td>Books per request<\/td>\n<td>1<\/td>\n<td>18 on the fixture below<\/td>\n<\/tr>\n<tr>\n<td>Sharp books<\/td>\n<td>Blocked or geo-locked<\/td>\n<td>Pinnacle, SBOBet, Circa<\/td>\n<\/tr>\n<tr>\n<td>Prediction markets<\/td>\n<td>Separate integration each<\/td>\n<td>Kalshi and Polymarket in the same payload<\/td>\n<\/tr>\n<tr>\n<td>Price formats<\/td>\n<td>Parse the DOM string yourself<\/td>\n<td><code>price<\/code>, <code>priceAmerican<\/code>, <code>priceFractional<\/code><\/td>\n<\/tr>\n<tr>\n<td>Historical prices<\/td>\n<td>Build your own archive<\/td>\n<td><code>\/historical-odds<\/code>, free tier<\/td>\n<\/tr>\n<tr>\n<td>Breaks when<\/td>\n<td>The book ships new CSS<\/td>\n<td>It does not<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/figure>\n<h2>Setup: one file, zero installs<\/h2>\n<p>Grab a free API key, then create a project. The <code>.mjs<\/code> extension gives you ES modules and top level <code>await<\/code> without a <code>package.json<\/code>.<\/p>\n<pre class=\"wp-block-code\"><code>node --version    # needs v18 or later\nmkdir odds && cd odds\ntouch lib.mjs<\/code><\/pre>\n<p>Authentication is a query parameter, not a header. This trips up people who expect <code>Authorization: Bearer<\/code>. Put the key in the URL and every endpoint works the same way.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ lib.mjs\nexport const API_KEY = process.env.ODDSPAPI_KEY;\nexport const BASE = \"https:\/\/api.oddspapi.io\/v4\";\nexport const sleep = (ms) =&gt; new Promise((r) =&gt; setTimeout(r, ms));\n\nexport async function op(path, params = {}, tries = 3) {\n  const url = new URL(`${BASE}\/${path}`);\n  url.searchParams.set(\"apiKey\", API_KEY);          \/\/ query param, NOT a header\n  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);\n\n  for (let attempt = 0; attempt &lt; tries; attempt++) {\n    const res = await fetch(url);\n    const body = await res.json();\n\n    if (res.status === 429) {                        \/\/ the API tells you how long to wait\n      const waitMs = body?.error?.retryMs ?? 2000;\n      await sleep(waitMs + 100);\n      continue;\n    }\n    if (!res.ok) throw new Error(`${res.status} ${JSON.stringify(body)}`);\n    return body;\n  }\n  throw new Error(`rate limited on \/${path} after ${tries} tries`);\n}<\/code><\/pre>\n<p>That <code>retryMs<\/code> field matters more than it looks. Read the rate limit section further down before you write any loop.<\/p>\n<h3>First call: list the sports<\/h3>\n<pre class=\"wp-block-code\"><code>\/\/ sports.mjs\nimport { op } from \".\/lib.mjs\";\n\nconst sports = await op(\"sports\");\nconsole.log(`${sports.length} sports`);\nconsole.log(sports.slice(0, 5));<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>$ ODDSPAPI_KEY=your_key node sports.mjs\n69 sports\n[\n  { sportId: 10, slug: 'soccer', sportName: 'Soccer' },\n  { sportId: 11, slug: 'basketball', sportName: 'Basketball' },\n  { sportId: 12, slug: 'tennis', sportName: 'Tennis' },\n  { sportId: 13, slug: 'baseball', sportName: 'Baseball' },\n  { sportId: 14, slug: 'american-football', sportName: 'American Football' }\n]<\/code><\/pre>\n<h2>Step 2: find fixtures (and the date window trap)<\/h2>\n<p><code>\/fixtures<\/code> takes a <code>sportId<\/code> and a <code>from<\/code> and <code>to<\/code> date range, up to 10 days apart. The obvious query for today&#8217;s games returns almost nothing, and it took a measurement to work out why.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ window.mjs\nimport { op, sleep } from \".\/lib.mjs\";\nconst d = (n) =&gt; new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);\n\nfor (const to of [d(1), d(2), d(3)]) {\n  const fx = await op(\"fixtures\", { sportId: 13, from: d(0), to });\n  const perDay = {};\n  for (const f of fx) {\n    const day = f.startTime.slice(0, 10);\n    perDay[day] = (perDay[day] ?? 0) + 1;\n  }\n  console.log(`from ${d(0)} to ${to}:`, JSON.stringify(perDay));\n  await sleep(1200);\n}<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>from 2026-08-10 to 2026-08-11: {\"2026-08-10\":45}\nfrom 2026-08-10 to 2026-08-12: {\"2026-08-10\":45,\"2026-08-11\":107,\"2026-08-12\":2}\nfrom 2026-08-10 to 2026-08-13: {\"2026-08-10\":45,\"2026-08-11\":107,\"2026-08-12\":112,\"2026-08-13\":2}<\/code><\/pre>\n<p>Look at the last day in each range. It carries 2 fixtures, and both of those start at exactly <code>00:00:00Z<\/code>. So <code>to<\/code> is a timestamp at midnight UTC of that date, not a whole day. Ask for <code>from=today&amp;to=today<\/code> and you get the 2 games that started at midnight, which is why a same-day query looks like the sport is dead.<\/p>\n<p><strong>Rule: set <code>to<\/code> to the day after the last day you want.<\/strong><\/p>\n<pre class=\"wp-block-code\"><code>\/\/ fixtures.mjs\nimport { op } from \".\/lib.mjs\";\nconst day = (n) =&gt; new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);\n\nconst fixtures = await op(\"fixtures\", { sportId: 13, from: day(0), to: day(1) });\nconst mlb = fixtures.filter((f) =&gt; f.tournamentName === \"MLB\" &amp;&amp; f.hasOdds);\n\nfor (const f of mlb) {\n  console.log(f.fixtureId, f.startTime, `${f.participant1Name} v ${f.participant2Name}`);\n}<\/code><\/pre>\n<p>Two fields do the work here. <code>tournamentName<\/code> filters the league, because <code>sportId: 13<\/code> also returns NPB, KBO, the Mexican League and every minor league affiliate. <code>hasOdds<\/code> tells you whether <code>\/odds<\/code> will return prices at all: skip anything false and you save a request.<\/p>\n<h2>Step 3: the odds payload, and what nobody documents<\/h2>\n<p>One <code>\/odds<\/code> call on the Dodgers fixture returned <strong>2.36 MB of JSON<\/strong>: 18 bookmakers, 258 distinct markets, 6,372 individual prices. The nesting runs five levels deep.<\/p>\n<pre class=\"wp-block-code\"><code>bookmakerOdds\n  \u2514\u2500\u2500 \"pinnacle\"\n        \u251c\u2500\u2500 bookmakerIsActive: true\n        \u251c\u2500\u2500 bookmakerFixtureId: \"1633332366\"\n        \u251c\u2500\u2500 fixturePath: \"https:\/\/www.pinnacle.com\/en\/e\/e\/e\/1633332366\"\n        \u251c\u2500\u2500 suspended: false\n        \u2514\u2500\u2500 markets\n              \u2514\u2500\u2500 \"131\"                          \/\/ market id\n                    \u251c\u2500\u2500 bookmakerMarketId: \"...\"\n                    \u251c\u2500\u2500 marketActive: true\n                    \u2514\u2500\u2500 outcomes\n                          \u2514\u2500\u2500 \"131\"              \/\/ outcome id\n                                \u2514\u2500\u2500 players\n                                      \u2514\u2500\u2500 \"0\"    \/\/ the price object\n                                            \u251c\u2500\u2500 price: 1.337\n                                            \u251c\u2500\u2500 priceAmerican: \"-297\"\n                                            \u251c\u2500\u2500 active: true\n                                            \u251c\u2500\u2500 limit: 11127\n                                            \u2514\u2500\u2500 mainLine: true<\/code><\/pre>\n<p>Three of those book level fields are worth your attention, and none of them appear in a typical tutorial:<\/p>\n<ul>\n<li><code>suspended<\/code> and <code>bookmakerIsActive<\/code> flag a book that has pulled its market on this fixture. SBOBet shipped <code>suspended: true<\/code> on our capture while still returning prices, and its numbers were the worst on the board.<\/li>\n<li><code>fixturePath<\/code> is a deep link to the book&#8217;s own event page. 16 of the 18 books carried one, including <code>https:\/\/sportsbook.draftkings.com\/event\/34500289<\/code> and <code>https:\/\/polymarket.com\/event\/mlb-kc-lad-2026-08-10<\/code>. If you build a comparison site, that is your outbound click.<\/li>\n<li><code>bookmakerFixtureId<\/code> is the book&#8217;s internal ID, useful when you reconcile against another feed.<\/li>\n<\/ul>\n<h3>Parse the moneyline across every book<\/h3>\n<p>MLB moneyline is market <code>131<\/code>, with outcome <code>131<\/code> for participant 1 and <code>132<\/code> for participant 2. Optional chaining does the defensive work: not every book prices every market.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ board.mjs\nimport { op } from \".\/lib.mjs\";\n\nconst data = await op(\"odds\", { fixtureId: \"id1300010963302689\" });\nconst books = data.bookmakerOdds;\n\nconst price = (book, marketId, outcomeId) =&gt;\n  book.markets?.[marketId]?.outcomes?.[outcomeId]?.players?.[\"0\"];\n\nconst board = [];\nfor (const [slug, book] of Object.entries(books)) {\n  const home = price(book, \"131\", \"131\");\n  const away = price(book, \"131\", \"132\");\n  if (!home || !away) continue;\n  if (home.active === false || away.active === false) continue;   \/\/ see the traps section\n\n  board.push({\n    slug,\n    suspended: book.suspended,\n    home: home.price,\n    away: away.price,\n    vig: +(((1 \/ home.price + 1 \/ away.price) - 1) * 100).toFixed(2),\n  });\n}\n\nboard.sort((a, b) =&gt; a.vig - b.vig);\nconsole.table(board);<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 (index) \u2502 slug               \u2502 suspended \u2502 home  \u2502 away  \u2502 vig  \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 0       \u2502 'kalshi'           \u2502 false     \u2502 1.333 \u2502 3.846 \u2502 1.02 \u2502\n\u2502 1       \u2502 'polymarket'       \u2502 false     \u2502 1.351 \u2502 3.704 \u2502 1.02 \u2502\n\u2502 2       \u2502 'hardrockbet'      \u2502 false     \u2502 1.333 \u2502 3.75  \u2502 1.69 \u2502\n\u2502 3       \u2502 'pinnacle'         \u2502 false     \u2502 1.337 \u2502 3.62  \u2502 2.42 \u2502\n\u2502 4       \u2502 'circasports'      \u2502 false     \u2502 1.333 \u2502 3.62  \u2502 2.64 \u2502\n\u2502 5       \u2502 'caesars'          \u2502 false     \u2502 1.323 \u2502 3.65  \u2502 2.98 \u2502\n\u2502 6       \u2502 'williamhill'      \u2502 false     \u2502 1.323 \u2502 3.65  \u2502 2.98 \u2502\n\u2502 7       \u2502 'fanduel'          \u2502 false     \u2502 1.3   \u2502 3.65  \u2502 4.32 \u2502\n\u2502 8       \u2502 'draftkings'       \u2502 false     \u2502 1.308 \u2502 3.56  \u2502 4.54 \u2502\n\u2502 9       \u2502 'betmgm'           \u2502 false     \u2502 1.3   \u2502 3.6   \u2502 4.7  \u2502\n\u2502 10      \u2502 'borgata'          \u2502 false     \u2502 1.3   \u2502 3.6   \u2502 4.7  \u2502\n\u2502 11      \u2502 'pointsbet.com.au' \u2502 false     \u2502 1.31  \u2502 3.5   \u2502 4.91 \u2502\n\u2502 12      \u2502 'fourwinds'        \u2502 false     \u2502 1.3   \u2502 3.55  \u2502 5.09 \u2502\n\u2502 13      \u2502 'bet365'           \u2502 false     \u2502 1.28  \u2502 3.6   \u2502 5.9  \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/code><\/pre>\n<p>Fourteen books, and the margin runs from 1.02% at the prediction markets to 5.9% at Bet365. Same game, same moment.<\/p>\n<h3>Dedupe before you average<\/h3>\n<p>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 <strong>12 independent opinions<\/strong>.<\/p>\n<pre class=\"wp-block-code\"><code>const distinct = new Map();\nfor (const row of board) {\n  const key = `${row.home}|${row.away}`;\n  distinct.set(key, [...(distinct.get(key) ?? []), row.slug]);\n}\nfor (const [quote, slugs] of distinct)\n  if (slugs.length &gt; 1) console.log(\"identical:\", slugs.join(\" == \"), quote);\n\n\/\/ identical: caesars == williamhill 1.323|3.65\n\/\/ identical: betmgm == borgata 1.3|3.6<\/code><\/pre>\n<p>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 <a href=\"https:\/\/oddspapi.io\/blog\/consensus-odds-fair-odds-calculator-python\/\">consensus odds guide<\/a> covers the full weighting method.<\/p>\n<h2>Step 4: best price and the fair price<\/h2>\n<p>Line shopping in JavaScript is a <code>reduce<\/code>. Filter suspended books out first.<\/p>\n<pre class=\"wp-block-code\"><code>const live = board.filter((r) =&gt; !r.suspended);\nconst bestHome = live.reduce((m, r) =&gt; (r.home &gt; m.home ? r : m));\nconst bestAway = live.reduce((m, r) =&gt; (r.away &gt; m.away ? r : m));\n\nconsole.log(\"best Dodgers\", bestHome.home, \"@\", bestHome.slug);\nconsole.log(\"best Royals \", bestAway.away, \"@\", bestAway.slug);\n\n\/\/ best Dodgers 1.351 @ polymarket\n\/\/ best Royals  3.846 @ kalshi<\/code><\/pre>\n<p>Now strip the margin out of Pinnacle&#8217;s price to see what those numbers are worth. The power method solves for the exponent <code>k<\/code> that makes the implied probabilities sum to 1, and bisection converges in a few lines.<\/p>\n<pre class=\"wp-block-code\"><code>function devigPower(prices) {\n  const implied = prices.map((p) =&gt; 1 \/ p);\n  let lo = 0.5, hi = 2;\n  for (let i = 0; i &lt; 200; i++) {\n    const k = (lo + hi) \/ 2;\n    const sum = implied.reduce((t, x) =&gt; t + x ** k, 0);\n    if (sum &gt; 1) lo = k; else hi = k;\n  }\n  return implied.map((x) =&gt; x ** ((lo + hi) \/ 2));\n}\n\nconst fair = devigPower([1.337, 3.62]);          \/\/ pinnacle\nconsole.log(fair.map((f) =&gt; (f * 100).toFixed(1) + \"%\"));   \/\/ [ '73.9%', '26.1%' ]\nconsole.log(fair.map((f) =&gt; (1 \/ f).toFixed(3)));           \/\/ [ '1.354', '3.826' ]<\/code><\/pre>\n<p>Pinnacle&#8217;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 &#8220;best available price&#8221; and &#8220;value&#8221; as separate claims: the first is measurable, the second needs a model. The <a href=\"https:\/\/oddspapi.io\/blog\/no-vig-odds-api\/\">no-vig guide<\/a> compares the proportional, power and Shin methods if you want the maths.<\/p>\n<h2>Player props: the <code>players<\/code> dict is not what you expect<\/h2>\n<p>On a game line, <code>players<\/code> has one key: <code>\"0\"<\/code>. On a player prop, it is keyed by player ID, and a single outcome holds the whole lineup. Hardcode <code>players[\"0\"]<\/code> and every prop market reads as empty.<\/p>\n<p>Anytime home run is market <code>131663<\/code>, outcome <code>131664<\/code> (&#8220;1+&#8221;).<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ props.mjs\nimport { op } from \".\/lib.mjs\";\n\nconst data = await op(\"odds\", { fixtureId: \"id1300010963302689\" });\nconst byPlayer = new Map();\n\nfor (const [slug, book] of Object.entries(data.bookmakerOdds)) {\n  const outcome = book.markets?.[\"131663\"]?.outcomes?.[\"131664\"];\n  if (!outcome) continue;\n\n  for (const [playerId, p] of Object.entries(outcome.players)) {\n    if (playerId === \"0\" || p.active === false) continue;         \/\/ \"0\" is the game-line slot\n    const row = byPlayer.get(playerId) ?? { name: p.playerName, quotes: [] };\n    row.quotes.push({ slug, price: p.price });\n    byPlayer.set(playerId, row);\n  }\n}\n\nconst rows = [...byPlayer.values()].map((r) =&gt; {\n  const best = r.quotes.reduce((m, q) =&gt; (q.price &gt; m.price ? q : m));\n  const worst = r.quotes.reduce((m, q) =&gt; (q.price &lt; m.price ? q : m));\n  return { player: r.name, books: r.quotes.length, best: best.price, at: best.slug,\n           worst: worst.price, spread: +(((best.price \/ worst.price) - 1) * 100).toFixed(1) };\n});\nconsole.table(rows.sort((a, b) =&gt; a.best - b.best).slice(0, 6));<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\n\u2502 (index) \u2502 player               \u2502 books \u2502 best \u2502 at        \u2502 worst \u2502 spread \u2502\n\u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u253c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\n\u2502 0       \u2502 'Ohtani, Shohei'     \u2502 5     \u2502 3.45 \u2502 'bet365'  \u2502 3.1   \u2502 11.3   \u2502\n\u2502 1       \u2502 'Perez, Salvador'    \u2502 5     \u2502 5.5  \u2502 'bet365'  \u2502 5     \u2502 10     \u2502\n\u2502 2       \u2502 'Hernandez, Teoscar' \u2502 5     \u2502 5.5  \u2502 'bet365'  \u2502 4.9   \u2502 12.2   \u2502\n\u2502 3       \u2502 'Pages, Andy'        \u2502 5     \u2502 5.5  \u2502 'bet365'  \u2502 4.7   \u2502 17     \u2502\n\u2502 4       \u2502 'Betts, Mookie'      \u2502 5     \u2502 5.6  \u2502 'fanduel' \u2502 4.9   \u2502 14.3   \u2502\n\u2502 5       \u2502 'Freeman, Freddie'   \u2502 5     \u2502 5.75 \u2502 'bet365'  \u2502 5.25  \u2502 9.5    \u2502\n\u2502 6       \u2502 'Tucker, Kyle'       \u2502 5     \u2502 6.25 \u2502 'bet365'  \u2502 5.25  \u2502 19     \u2502\n\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/code><\/pre>\n<p>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 <a href=\"https:\/\/oddspapi.io\/blog\/mlb-player-props-api\/\">MLB player props guide<\/a> goes through the full batter and pitcher catalogue.<\/p>\n<h2>Four JavaScript traps<\/h2>\n<h3>1. <code>Promise.all<\/code> will get you rate limited<\/h3>\n<p>Fanning out with <code>Promise.all<\/code> is the idiomatic JavaScript move, and it is exactly wrong here. The API rate limits per endpoint. Six concurrent <code>\/odds<\/code> calls, measured:<\/p>\n<pre class=\"wp-block-code\"><code>Promise.all:            0.68s | 200s: 1 | 429s: 5\nsequential + 1s sleep:  9.48s | 200s: 6 | 429s: 0<\/code><\/pre>\n<p>The 429 body is structured JSON and it tells you the exact wait:<\/p>\n<pre class=\"wp-block-code\"><code>{\n  \"error\": {\n    \"code\": \"RATE_LIMITED\",\n    \"details\": \"Please wait 0.37 seconds before making another request to \/v4\/odds.\",\n    \"retryAfter\": \"0.37 seconds\",\n    \"retryMs\": 372\n  }\n}<\/code><\/pre>\n<p>Two consequences. First, pace same-endpoint calls at roughly one per second and honour <code>retryMs<\/code> when you miss, which is what the <code>op()<\/code> helper at the top does. Second, and this bites harder: <strong>a 429 body is valid JSON<\/strong>. Code that does <code>const { bookmakerOdds } = await res.json()<\/code> and checks whether the key exists reads a rate limit error as &#8220;this fixture has no odds&#8221; and moves on. Check <code>res.status<\/code> first.<\/p>\n<h3>2. JavaScript reorders your market IDs<\/h3>\n<p>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&#8217;s market list starts at <code>\"1364\"<\/code>. After <code>JSON.parse<\/code>:<\/p>\n<pre class=\"wp-block-code\"><code>Object.keys(data.bookmakerOdds.pinnacle.markets).slice(0, 8)\n\/\/ [ '131', '1314', '1316', '1318', '1320', '1322', '1324', '1326' ]<\/code><\/pre>\n<p>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 <code>\/markets<\/code>, or pick the line the most books quote.<\/p>\n<h3>3. <code>active<\/code> lives on the price, not the outcome<\/h3>\n<p>The outcome object has exactly one key: <code>players<\/code>. Every flag you care about sits one level deeper, on the price object.<\/p>\n<pre class=\"wp-block-code\"><code>const outcome = book.markets[\"131\"].outcomes[\"131\"];\nObject.keys(outcome);            \/\/ [ 'players' ]  <-- outcome.active does not exist\noutcome.players[\"0\"].active;     \/\/ true           <-- it lives here<\/code><\/pre>\n<p>Test for <code>active === false<\/code> rather than truthiness. The field can arrive as <code>null<\/code> on some pre-game payloads, and <code>if (!p.active)<\/code> then discards good prices without a word. On our capture, 433 of 6,372 prices were <code>active: false<\/code>, which is the suspended alt-line ladder, and the other 5,939 were live.<\/p>\n<h3>4. The API allows browser calls, which is the problem<\/h3>\n<p>The API returns <code>access-control-allow-origin: *<\/code>, so a <code>fetch<\/code> 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.<\/p>\n<p>Proxy it. This is the whole server, using only <code>node:http<\/code>, with a 10 second cache that also keeps you under the rate limit when several users load the same fixture:<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ proxy.mjs\nimport { createServer } from \"node:http\";\n\nconst API_KEY = process.env.ODDSPAPI_KEY;      \/\/ never leaves the server\nconst BASE = \"https:\/\/api.oddspapi.io\/v4\";\nconst cache = new Map();\nconst TTL_MS = 10_000;\n\ncreateServer(async (req, res) =&gt; {\n  const { pathname, searchParams } = new URL(req.url, \"http:\/\/localhost\");\n  if (pathname !== \"\/odds\") return res.writeHead(404).end();\n\n  const fixtureId = searchParams.get(\"fixtureId\");\n  const hit = cache.get(fixtureId);\n  if (hit &amp;&amp; Date.now() - hit.at &lt; TTL_MS) {\n    res.writeHead(200, { \"content-type\": \"application\/json\", \"x-cache\": \"hit\" });\n    return res.end(hit.body);\n  }\n\n  const upstream = await fetch(`${BASE}\/odds?apiKey=${API_KEY}&amp;fixtureId=${fixtureId}`);\n  const body = JSON.stringify(await upstream.json());\n  cache.set(fixtureId, { at: Date.now(), body });\n\n  res.writeHead(upstream.status, { \"content-type\": \"application\/json\", \"x-cache\": \"miss\" });\n  res.end(body);\n}).listen(8787);<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>$ curl -sD- -o \/dev\/null \"localhost:8787\/odds?fixtureId=id1300010963302689\" | grep x-cache\nx-cache: miss\n$ curl -sD- -o \/dev\/null \"localhost:8787\/odds?fixtureId=id1300010963302689\" | grep x-cache\nx-cache: hit<\/code><\/pre>\n<p>Your front end calls <code>\/odds<\/code> on your own origin. The key stays in the environment.<\/p>\n<h2>TypeScript definitions<\/h2>\n<p>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.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ oddspapi.d.ts\nexport interface ExchangeLevel {\n  cents: number;\n  price: number;\n  size: number;\n  limit: number;\n}\n\nexport interface ExchangeMeta {\n  back?: ExchangeLevel[];\n  lay?: ExchangeLevel[];\n  bookmakerLayOutcomeId?: string;\n}\n\nexport interface Price {\n  active: boolean;\n  betslip: string | null;\n  bookmakerOutcomeId: string;\n  bookmakerChangedAt: string | null;   \/\/ when the book moved it\n  changedAt: string;                   \/\/ when OddsPapi saw the move\n  limit: number | null;                \/\/ Pinnacle and exchanges only\n  playerName: string | null;           \/\/ \"Last, First\" on props\n  price: number;                       \/\/ decimal\n  priceAmerican: string;               \/\/ string, not a number\n  priceFractional: string;\n  mainLine: boolean;\n  exchangeMeta: ExchangeMeta | null;\n}\n\nexport interface Outcome {\n  players: Record&lt;string, Price&gt;;      \/\/ \"0\" on game lines, player id on props\n}\n\nexport interface Market {\n  bookmakerMarketId: string;\n  marketActive: boolean;\n  outcomes: Record&lt;string, Outcome&gt;;\n}\n\nexport interface BookmakerOdds {\n  bookmakerIsActive: boolean;\n  bookmakerFixtureId: string;\n  fixturePath: string;                 \/\/ deep link to the book's event page\n  suspended: boolean;\n  markets: Record&lt;string, Market&gt;;\n}\n\nexport interface OddsResponse {\n  fixtureId: string;\n  participant1Id: number;\n  participant2Id: number;\n  sportId: number;\n  tournamentId: number;\n  seasonId: number;\n  statusId: number;\n  hasOdds: boolean;\n  startTime: string;\n  trueStartTime: string | null;\n  trueEndTime: string | null;\n  updatedAt: string;\n  bookmakerOdds: Record&lt;string, BookmakerOdds&gt;;\n}<\/code><\/pre>\n<p>Two notes. <code>priceAmerican<\/code> and <code>priceFractional<\/code> are strings, so cast before arithmetic. <code>exchangeMeta<\/code> is <code>null<\/code> 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 <code>back[0]<\/code>.<\/p>\n<h3>Resolving market names<\/h3>\n<p>The <code>\/markets<\/code> catalogue is a lookup table, not a per-sport list. It returned <strong>32,815 rows in about one second<\/strong>, and it is global: the same rows come back whatever <code>sportId<\/code> you pass. Read the market IDs off a live <code>\/odds<\/code> payload, then use the catalogue to name them.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ names.mjs\nimport { op } from \".\/lib.mjs\";\n\nconst catalogue = await op(\"markets\", { sportId: 13 });\nconst marketName = new Map(catalogue.map((m) =&gt; [String(m.marketId), m.marketName]));\nconst handicap = new Map(catalogue.map((m) =&gt; [String(m.marketId), m.handicap]));\n\nconst data = await op(\"odds\", { fixtureId: \"id1300010963302689\" });\nconst bookCount = {};\nfor (const book of Object.values(data.bookmakerOdds))\n  for (const id of Object.keys(book.markets)) bookCount[id] = (bookCount[id] ?? 0) + 1;\n\nfor (const [id, n] of Object.entries(bookCount).sort((a, b) =&gt; b[1] - a[1]).slice(0, 6))\n  console.log(`${n} books | ${id} | ${marketName.get(id)} | line ${handicap.get(id)}`);<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>15 books | 131    | Winner (incl. extra innings)                   | line 0\n15 books | 1322   | Over Under (incl. extra innings)               | line 7.5\n15 books | 13806  | Over Under First Inning                        | line 0.5\n13 books | 1326   | Over Under (incl. extra innings)               | line 8.5\n13 books | 13100  | First Inning Result                            | line 0\n13 books | 131621 | Over Under Strikeouts (incl. extra innings)    | line 4.5<\/code><\/pre>\n<p>Every total line has its own market ID: 7.5 runs is <code>1322<\/code>, 8.5 runs is <code>1326<\/code>. There is no single \"totals\" market to hardcode. Sorting by book count is how you find the line the market agrees on.<\/p>\n<h2>Historical odds, free<\/h2>\n<p>The same fixture ID works against <code>\/historical-odds<\/code>, which returns the full price history rather than the current price. Two differences to code around: the top-level key is <code>bookmakers<\/code> rather than <code>bookmakerOdds<\/code>, and <code>players[\"0\"]<\/code> is an <strong>array<\/strong> of snapshots. Maximum 3 bookmakers per call.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ history.mjs\nimport { op } from \".\/lib.mjs\";\n\nconst hist = await op(\"historical-odds\", {\n  fixtureId: \"id1300010963302689\",\n  bookmakers: \"pinnacle,draftkings,fanduel\",       \/\/ max 3\n});\n\nfor (const [slug, book] of Object.entries(hist.bookmakers)) {\n  const snaps = book.markets[\"131\"]?.outcomes[\"131\"]?.players[\"0\"];\n  if (!Array.isArray(snaps)) continue;\n\n  let changes = 0;\n  for (let i = 1; i &lt; snaps.length; i++)\n    if (snaps[i].price !== snaps[i - 1].price) changes++;\n\n  console.log(slug.padEnd(12),\n    \"snapshots\", String(snaps.length).padStart(4),\n    \"| changes\", String(changes).padStart(3),\n    \"| open\", snaps[0].price, \"-&gt; latest\", snaps.at(-1).price,\n    \"| limit\", snaps[0].limit, \"-&gt;\", snaps.at(-1).limit);\n}<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>draftkings   snapshots    7 | changes   6 | open 1.339 -> latest 1.308 | limit null -> null\npinnacle     snapshots   37 | changes  23 | open 1.348 -> latest 1.337 | limit 5387 -> 11127\nfanduel      snapshots    4 | changes   1 | open 1.32  -> latest 1.3   | limit null -> null<\/code><\/pre>\n<p>Pinnacle repriced the Dodgers 23 times while FanDuel moved once. The <code>limit<\/code> 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 <code>null<\/code> because their limits are per-account.<\/p>\n<p>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 <a href=\"https:\/\/oddspapi.io\/blog\/odds-database-python-sqlite\/\">odds database walkthrough<\/a> covers change-only inserts, and <a href=\"https:\/\/oddspapi.io\/blog\/websocket-odds-api-real-time-betting-data\/\">the WebSocket feed<\/a> pushes updates instead of making you poll.<\/p>\n<h2>Putting it together<\/h2>\n<p>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.<\/p>\n<pre class=\"wp-block-code\"><code>\/\/ scan.mjs\nimport { op, sleep } from \".\/lib.mjs\";\n\nconst day = (n) =&gt; new Date(Date.now() + n * 864e5).toISOString().slice(0, 10);\nconst fixtures = await op(\"fixtures\", { sportId: 13, from: day(0), to: day(1) });\nconst games = fixtures.filter((f) =&gt; f.tournamentName === \"MLB\" &amp;&amp; f.hasOdds);\n\nfor (const game of games) {\n  const data = await op(\"odds\", { fixtureId: game.fixtureId });\n  const quotes = [];\n\n  for (const [slug, book] of Object.entries(data.bookmakerOdds ?? {})) {\n    if (book.suspended) continue;\n    const home = book.markets?.[\"131\"]?.outcomes?.[\"131\"]?.players?.[\"0\"];\n    const away = book.markets?.[\"131\"]?.outcomes?.[\"132\"]?.players?.[\"0\"];\n    if (!home || !away || home.active === false || away.active === false) continue;\n    quotes.push({ slug, home: home.price, away: away.price });\n  }\n  if (quotes.length &lt; 2) continue;\n\n  const hi = Math.max(...quotes.map((q) =&gt; q.home));\n  const lo = Math.min(...quotes.map((q) =&gt; q.home));\n  console.log(\n    `${game.participant1Name} v ${game.participant2Name}`.padEnd(42),\n    `${quotes.length} books | spread ${(((hi \/ lo) - 1) * 100).toFixed(2)}%`\n  );\n\n  await sleep(1000);        \/\/ one call per second per endpoint\n}<\/code><\/pre>\n<pre class=\"wp-block-code\"><code>games: 4\nToronto Blue Jays v Boston Red Sox          14 books | spread 6.38%\nAtlanta Braves v New York Mets              13 books | spread 6.48%\nMinnesota Twins v Baltimore Orioles         14 books | spread 7.37%\nSt. Louis Cardinals v Philadelphia Phillies 14 books | spread 9.06%<\/code><\/pre>\n<p>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.<\/p>\n<p>That is the pattern every tool on this blog is built on. Swap <code>sportId<\/code> for soccer or the NFL, swap the market ID, and the shape holds. If you want the same walkthrough in Python, start with <a href=\"https:\/\/oddspapi.io\/blog\/first-odds-api-call-python\/\">your first odds API call<\/a>, then <a href=\"https:\/\/oddspapi.io\/blog\/line-shopping-python-best-odds\/\">line shopping across 350+ books<\/a>.<\/p>\n<h2>FAQ<\/h2>\n<h3>Is there an official JavaScript SDK for OddsPapi?<\/h3>\n<p>You do not need one. Every endpoint is a GET with an <code>apiKey<\/code> query parameter, and Node 18 and later include <code>fetch<\/code>. The <code>op()<\/code> helper in this guide is 20 lines and handles rate limit retries, which is the only thing an SDK would add.<\/p>\n<h3>Can I call the odds API directly from the browser?<\/h3>\n<p>The API sends <code>access-control-allow-origin: *<\/code>, 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.<\/p>\n<h3>Why does my fixtures query return almost nothing?<\/h3>\n<p>The <code>to<\/code> parameter is a midnight UTC boundary, not a whole day. <code>from=2026-08-10&amp;to=2026-08-10<\/code> returns only fixtures starting at exactly 00:00Z. Set <code>to<\/code> to the day after the last day you want.<\/p>\n<h3>Why is my player prop parser returning empty?<\/h3>\n<p>On game lines the <code>players<\/code> dict has a single <code>\"0\"<\/code> key. On player props it is keyed by player ID, and each entry carries a <code>playerName<\/code> like <code>\"Ohtani, Shohei\"<\/code>. Iterate the dict and skip <code>\"0\"<\/code> rather than hardcoding it.<\/p>\n<h3>How fast can I poll?<\/h3>\n<p>Roughly one request per second per endpoint on the free tier. Concurrency does not help: six parallel <code>\/odds<\/code> calls returned five 429s in our test while the sequential version returned six clean responses. Honour <code>retryMs<\/code> from the error body, and use the WebSocket feed if you need push updates rather than polling.<\/p>\n<h2>Get your key<\/h2>\n<p>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.<\/p>\n<p><strong><a href=\"https:\/\/oddspapi.io\/\">Grab a free API key<\/a> and run the first script in this guide in the next five minutes.<\/strong><\/p>\n<p><script type=\"application\/ld+json\">\n{\n  \"@context\": \"https:\/\/schema.org\",\n  \"@type\": \"FAQPage\",\n  \"mainEntity\": [\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Is there an official JavaScript SDK for OddsPapi?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"You do not need one. Every endpoint is a GET with an apiKey query parameter, and Node 18 and later include fetch. A 20 line helper handles rate limit retries, which is the only thing an SDK would add.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Can I call the odds API directly from the browser?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"The API sends access-control-allow-origin: *, so the request succeeds, but your API key ends up in the client bundle and in the network tab. Run a small Node proxy and call your own origin instead.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why does my fixtures query return almost nothing?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"Why is my player prop parser returning empty?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"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.\"\n      }\n    },\n    {\n      \"@type\": \"Question\",\n      \"name\": \"How fast can I poll the OddsPapi API from Node.js?\",\n      \"acceptedAnswer\": {\n        \"@type\": \"Answer\",\n        \"text\": \"Roughly one request per second per endpoint on the free tier. Concurrency does not help: six parallel \/odds calls returned five 429 responses while the sequential version returned six clean ones. Honour retryMs from the error body.\"\n      }\n    }\n  ]\n}\n<\/script><\/p>\n<p><!--\nFocus Keyphrase: javascript odds api\nSEO Title: JavaScript Odds API: Resolve Main Lines Correctly in Node.js\nMeta Description: Fetch live bookmaker odds in JavaScript. Node.js tutorial with native fetch, TypeScript types, player props and free historical data from 350+ books.\nSlug: javascript-odds-api-nodejs\n--><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Fetch live bookmaker odds in JavaScript. Node.js tutorial with native fetch, TypeScript types, player props and free historical data from 350+ books.<\/p>\n","protected":false},"author":2,"featured_media":3652,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[8,84,85,9,86],"class_list":["post-3651","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-how-to-guides","tag-free-api","tag-javascript","tag-nodejs","tag-odds-api","tag-typescript"],"yoast_head":"<!-- This site is optimized with the Yoast SEO plugin v26.4 - https:\/\/yoast.com\/wordpress\/plugins\/seo\/ -->\n<title>JavaScript Odds API: Resolve Main Lines Correctly in Node.js | OddsPapi Blog<\/title>\n<meta name=\"robots\" content=\"index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1\" \/>\n<link rel=\"canonical\" href=\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\" \/>\n<meta property=\"og:locale\" content=\"en_US\" \/>\n<meta property=\"og:type\" content=\"article\" \/>\n<meta property=\"og:title\" content=\"JavaScript Odds API: Resolve Main Lines Correctly in Node.js | OddsPapi Blog\" \/>\n<meta property=\"og:description\" content=\"Fetch live bookmaker odds in JavaScript. Node.js tutorial with native fetch, TypeScript types, player props and free historical data from 350+ books.\" \/>\n<meta property=\"og:url\" content=\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\" \/>\n<meta property=\"og:site_name\" content=\"OddsPapi Blog\" \/>\n<meta property=\"article:published_time\" content=\"2026-08-19T10:00:00+00:00\" \/>\n<meta property=\"article:modified_time\" content=\"2026-09-07T14:01:51+00:00\" \/>\n<meta property=\"og:image\" content=\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp\" \/>\n\t<meta property=\"og:image:width\" content=\"2560\" \/>\n\t<meta property=\"og:image:height\" content=\"1429\" \/>\n\t<meta property=\"og:image:type\" content=\"image\/webp\" \/>\n<meta name=\"author\" content=\"Odds API Writer\" \/>\n<meta name=\"twitter:card\" content=\"summary_large_image\" \/>\n<meta name=\"twitter:image\" content=\"https:\/\/oddspapi.io\/logo-v2.webp\" \/>\n<meta name=\"twitter:creator\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:site\" content=\"@oddspapiapi\" \/>\n<meta name=\"twitter:label1\" content=\"Written by\" \/>\n\t<meta name=\"twitter:data1\" content=\"Odds API Writer\" \/>\n\t<meta name=\"twitter:label2\" content=\"Est. reading time\" \/>\n\t<meta name=\"twitter:data2\" content=\"11 minutes\" \/>\n<script type=\"application\/ld+json\" class=\"yoast-schema-graph\">{\"@context\":\"https:\/\/schema.org\",\"@graph\":[{\"@type\":\"Article\",\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#article\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\"},\"author\":{\"name\":\"Odds API Writer\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\"},\"headline\":\"JavaScript Odds API: Resolve Main Lines Correctly in Node.js\",\"datePublished\":\"2026-08-19T10:00:00+00:00\",\"dateModified\":\"2026-09-07T14:01:51+00:00\",\"mainEntityOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\"},\"wordCount\":1982,\"commentCount\":0,\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp\",\"keywords\":[\"Free API\",\"JavaScript\",\"Node.js\",\"Odds API\",\"TypeScript\"],\"articleSection\":[\"How To Guides\"],\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"CommentAction\",\"name\":\"Comment\",\"target\":[\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#respond\"]}]},{\"@type\":\"WebPage\",\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\",\"name\":\"JavaScript Odds API: Resolve Main Lines Correctly in Node.js | OddsPapi Blog\",\"isPartOf\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\"},\"primaryImageOfPage\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage\"},\"thumbnailUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp\",\"datePublished\":\"2026-08-19T10:00:00+00:00\",\"dateModified\":\"2026-09-07T14:01:51+00:00\",\"breadcrumb\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#breadcrumb\"},\"inLanguage\":\"en-US\",\"potentialAction\":[{\"@type\":\"ReadAction\",\"target\":[\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/\"]}]},{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp\",\"width\":2560,\"height\":1429,\"caption\":\"JavaScript Odds API - OddsPapi API Blog\"},{\"@type\":\"BreadcrumbList\",\"@id\":\"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#breadcrumb\",\"itemListElement\":[{\"@type\":\"ListItem\",\"position\":1,\"name\":\"Home\",\"item\":\"https:\/\/oddspapi.io\/blog\/\"},{\"@type\":\"ListItem\",\"position\":2,\"name\":\"JavaScript Odds API: Resolve Main Lines Correctly in Node.js\"}]},{\"@type\":\"WebSite\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#website\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"name\":\"OddsPapi\",\"description\":\"Sports Odds API Tutorials &amp; Guides\",\"publisher\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\"},\"alternateName\":\"Odds Papi\",\"potentialAction\":[{\"@type\":\"SearchAction\",\"target\":{\"@type\":\"EntryPoint\",\"urlTemplate\":\"https:\/\/oddspapi.io\/blog\/?s={search_term_string}\"},\"query-input\":{\"@type\":\"PropertyValueSpecification\",\"valueRequired\":true,\"valueName\":\"search_term_string\"}}],\"inLanguage\":\"en-US\"},{\"@type\":\"Organization\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#organization\",\"name\":\"OddsPapi\",\"url\":\"https:\/\/oddspapi.io\/blog\/\",\"logo\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\",\"url\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"contentUrl\":\"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png\",\"width\":135,\"height\":135,\"caption\":\"OddsPapi\"},\"image\":{\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/\"},\"sameAs\":[\"https:\/\/x.com\/oddspapiapi\"]},{\"@type\":\"Person\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13\",\"name\":\"Odds API Writer\",\"image\":{\"@type\":\"ImageObject\",\"inLanguage\":\"en-US\",\"@id\":\"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/\",\"url\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"contentUrl\":\"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g\",\"caption\":\"Odds API Writer\"},\"url\":\"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/\"}]}<\/script>\n<!-- \/ Yoast SEO plugin. -->","yoast_head_json":{"title":"JavaScript Odds API: Resolve Main Lines Correctly in Node.js | OddsPapi Blog","robots":{"index":"index","follow":"follow","max-snippet":"max-snippet:-1","max-image-preview":"max-image-preview:large","max-video-preview":"max-video-preview:-1"},"canonical":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/","og_locale":"en_US","og_type":"article","og_title":"JavaScript Odds API: Resolve Main Lines Correctly in Node.js | OddsPapi Blog","og_description":"Fetch live bookmaker odds in JavaScript. Node.js tutorial with native fetch, TypeScript types, player props and free historical data from 350+ books.","og_url":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/","og_site_name":"OddsPapi Blog","article_published_time":"2026-08-19T10:00:00+00:00","article_modified_time":"2026-09-07T14:01:51+00:00","og_image":[{"width":2560,"height":1429,"url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp","type":"image\/webp"}],"author":"Odds API Writer","twitter_card":"summary_large_image","twitter_image":"https:\/\/oddspapi.io\/logo-v2.webp","twitter_creator":"@oddspapiapi","twitter_site":"@oddspapiapi","twitter_misc":{"Written by":"Odds API Writer","Est. reading time":"11 minutes"},"schema":{"@context":"https:\/\/schema.org","@graph":[{"@type":"Article","@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#article","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/"},"author":{"name":"Odds API Writer","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13"},"headline":"JavaScript Odds API: Resolve Main Lines Correctly in Node.js","datePublished":"2026-08-19T10:00:00+00:00","dateModified":"2026-09-07T14:01:51+00:00","mainEntityOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/"},"wordCount":1982,"commentCount":0,"publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp","keywords":["Free API","JavaScript","Node.js","Odds API","TypeScript"],"articleSection":["How To Guides"],"inLanguage":"en-US","potentialAction":[{"@type":"CommentAction","name":"Comment","target":["https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#respond"]}]},{"@type":"WebPage","@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/","url":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/","name":"JavaScript Odds API: Resolve Main Lines Correctly in Node.js | OddsPapi Blog","isPartOf":{"@id":"https:\/\/oddspapi.io\/blog\/#website"},"primaryImageOfPage":{"@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage"},"thumbnailUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp","datePublished":"2026-08-19T10:00:00+00:00","dateModified":"2026-09-07T14:01:51+00:00","breadcrumb":{"@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#breadcrumb"},"inLanguage":"en-US","potentialAction":[{"@type":"ReadAction","target":["https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/"]}]},{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#primaryimage","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2026\/08\/javascript-odds-api-nodejs-scaled.webp","width":2560,"height":1429,"caption":"JavaScript Odds API - OddsPapi API Blog"},{"@type":"BreadcrumbList","@id":"https:\/\/oddspapi.io\/blog\/javascript-odds-api-nodejs\/#breadcrumb","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https:\/\/oddspapi.io\/blog\/"},{"@type":"ListItem","position":2,"name":"JavaScript Odds API: Resolve Main Lines Correctly in Node.js"}]},{"@type":"WebSite","@id":"https:\/\/oddspapi.io\/blog\/#website","url":"https:\/\/oddspapi.io\/blog\/","name":"OddsPapi","description":"Sports Odds API Tutorials &amp; Guides","publisher":{"@id":"https:\/\/oddspapi.io\/blog\/#organization"},"alternateName":"Odds Papi","potentialAction":[{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https:\/\/oddspapi.io\/blog\/?s={search_term_string}"},"query-input":{"@type":"PropertyValueSpecification","valueRequired":true,"valueName":"search_term_string"}}],"inLanguage":"en-US"},{"@type":"Organization","@id":"https:\/\/oddspapi.io\/blog\/#organization","name":"OddsPapi","url":"https:\/\/oddspapi.io\/blog\/","logo":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/","url":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","contentUrl":"https:\/\/oddspapi.io\/blog\/wp-content\/uploads\/2025\/11\/oddspapi.png","width":135,"height":135,"caption":"OddsPapi"},"image":{"@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/logo\/image\/"},"sameAs":["https:\/\/x.com\/oddspapiapi"]},{"@type":"Person","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/b6f21e649c4f556f0a95c23a0f1efa13","name":"Odds API Writer","image":{"@type":"ImageObject","inLanguage":"en-US","@id":"https:\/\/oddspapi.io\/blog\/#\/schema\/person\/image\/","url":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","contentUrl":"https:\/\/secure.gravatar.com\/avatar\/33b204f24af3d02e35b25ae730c0536121ca6a783fdb196e7611c9e49fcd13eb?s=96&d=mm&r=g","caption":"Odds API Writer"},"url":"https:\/\/oddspapi.io\/blog\/author\/andy-lavelle\/"}]}},"_links":{"self":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/3651","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/comments?post=3651"}],"version-history":[{"count":3,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/3651\/revisions"}],"predecessor-version":[{"id":3895,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/posts\/3651\/revisions\/3895"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media\/3652"}],"wp:attachment":[{"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/media?parent=3651"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/categories?post=3651"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/oddspapi.io\/blog\/wp-json\/wp\/v2\/tags?post=3651"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}