How to Build an Odds Comparison Tool with One API
Build an odds comparison tool in Node.js: pull one league from several bookmakers with one API key, match fixtures, show the best price and each book's margin.
An odds comparison tool lines up every bookmaker's price for the same fixture and highlights the best one. With PulseScore every book shares one JSON schema and one API key, so the whole tool fits in about 100 lines of Node.js: fetch one league from each book, match the fixtures, compare the prices.
This walkthrough builds a command-line version that prints a table per fixture with each book's 1X2 prices, its margin, the best price per outcome and a deep link where the bookmaker provides one. It runs on Node 18+ with the built-in fetch and no dependencies; turning it into a web page is covered at the end.
What will the tool print?
Man City v Arsenal, 2026-10-18T14:00:00.000Z
┌─────────┬──────────────┬──────┬──────┬──────┬────────┐
│ (index) │ book │ HOME │ DRAW │ AWAY │ margin │
├─────────┼──────────────┼──────┼──────┼──────┼────────┤
│ 0 │ 'bet365' │ 2.1 │ 3.6 │ 3.5 │ '4.0%' │
│ 1 │ 'betfair-sb' │ 2.05 │ 3.9 │ 3.4 │ '3.8%' │
│ 2 │ 'skybet' │ 2.15 │ 3.75 │ 3.45 │ '2.2%' │
│ 3 │ 'best' │ 2.15 │ 3.9 │ 3.5 │ '0.7%' │
└─────────┴──────────────┴──────┴──────┴──────┴────────┘
best prices: HOME @ skybet, DRAW @ betfair-sb, AWAY @ bet365
betfair-sb: https://www.betfair.com/betting/football/english-premier-league/man-city-v-arsenal/e-35974468The prices above are sample data. One table per fixture, one row per bookmaker, and a best row that takes the top price for each outcome from any book.
Step 1: Choose the bookmakers and one competition
Each book spells competitions its own way: bet365 lists the Premier League as United Kingdom||England Premier League, while Betfair Sportsbook and Sky Bet use English Premier League. Read the spelling once from the league field of any event in /soccer/events (or from /soccer/leagues) and keep it in config.
/soccer/leagues/{league}/events then returns every pre-match event of that league, full markets included, in one unpaginated response, so a refresh costs one request per bookmaker. URL-encode the league name, since bet365's contains ||.
// compare.mjs (Node 18+): PULSESCORE_API_KEY=your_key node compare.mjs
const API = "https://api.pulsescore.net/api";
const headers = {
"X-Secret": process.env.PULSESCORE_API_KEY,
"Accept-Encoding": "gzip",
};
// One competition, spelled the way each bookmaker spells it. Read the names
// once from the `league` field of /soccer/events (or from /soccer/leagues).
const BOOKS = {
bet365: { base: `${API}/v3/bet365`, league: "United Kingdom||England Premier League" },
"betfair-sb": { base: `${API}/betfair-sb`, league: "English Premier League" },
skybet: { base: `${API}/skybet`, league: "English Premier League" },
};
// A book's domain turns the event's `slug` into a deep link.
const SITES = { "betfair-sb": "https://www.betfair.com" };
const PACE_MS = 1000; // at most one request per second to any one bookmaker
const CACHE_MS = 15_000; // every viewer within 15 s gets the same answer
const lastCall = new Map();
const cache = new Map();
async function getJson(book, url) {
const hit = cache.get(url);
if (hit && Date.now() - hit.at < CACHE_MS) return hit.body;
const wait = (lastCall.get(book) ?? 0) + PACE_MS - Date.now();
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
lastCall.set(book, Date.now());
const res = await fetch(url, { headers });
if (res.status === 429) {
throw new Error(`${book}: 429, retry in ${res.headers.get("retry-after") ?? "a few"} s`);
}
if (!res.ok) throw new Error(`${book}: HTTP ${res.status}`);
const body = await res.json();
cache.set(url, { at: Date.now(), body });
return body;
}The rate limit is counted per bookmaker (1 req/sec per bookmaker on PRO, 3 req/sec on MAX, 6 req/sec on ULTRA), so the books are fetched in parallel and PACE_MS only spaces out requests to the same book. On STARTER the limit is 1 req/min per bookmaker: set PACE_MS to 60000 and keep the cache at least that long. Successful replies carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset headers if you would rather pace from those.
The cache matters more than the pacing: a page with a hundred viewers should still cost one request per book per refresh. On the free BASIC plan (500 requests a month) a three-book refresh costs three requests, which is plenty to build and demo the tool but not to run it all day.
Step 2: Match fixtures and read the prices
Event IDs are per bookmaker and team names vary: "Man City" against "Manchester City", "Chelsea FC" against "Chelsea". Inside one competition the same two teams never meet twice on the same day, so normalized home and away names plus the UTC date make a dependable fixture key.
const ALIASES = { "man city": "manchester city", "man utd": "manchester united", spurs: "tottenham hotspur" };
const NOISE = new Set(["fc", "afc", "cf", "sc", "ac", "fk", "the"]);
function norm(name) {
const plain = name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase();
const key = (plain.match(/[a-z0-9]+/g) ?? []).filter((w) => !NOISE.has(w)).join(" ");
return ALIASES[key] ?? key;
}
// Same teams on the same (UTC) day inside one competition = the same fixture.
const fixtureKey = (e) => `${norm(e.home)}|${norm(e.away)}|${(e.startTime ?? "").slice(0, 10)}`;
const OUTCOMES = ["HOME", "DRAW", "AWAY"];
// Full-time 1X2 prices of one event, or null if the market is missing or suspended.
function matchResult(event) {
const market = (event.markets ?? []).find(
(m) => m.canonicalMarket === "MATCH_RESULT" && m.period === "FULL_TIME" && m.isActive,
);
if (!market) return null;
const prices = {};
for (const s of market.selections) {
if (s.isActive && typeof s.odds === "number" && s.odds > 1) prices[s.canonicalOutcome] = s.odds;
}
return OUTCOMES.every((o) => prices[o]) ? prices : null;
}
async function compare() {
// The rate limit is per bookmaker, so all books are fetched at once.
const boards = await Promise.all(
Object.entries(BOOKS).map(async ([book, { base, league }]) => {
try {
const url = `${base}/soccer/leagues/${encodeURIComponent(league)}/events`;
const { events } = await getJson(book, url);
return [book, events.filter((e) => !e.live)];
} catch (err) {
console.error(err.message); // one failing book must not blank the table
return [book, []];
}
}),
);
const fixtures = new Map();
for (const [book, events] of boards) {
for (const event of events) {
const prices = matchResult(event);
if (!prices) continue;
const key = fixtureKey(event);
if (!fixtures.has(key)) fixtures.set(key, { event, books: {} });
fixtures.get(key).books[book] = { prices, slug: event.slug };
}
}
return [...fixtures.values()].filter((f) => Object.keys(f.books).length > 1);
}matchResult keeps only an active full-time MATCH_RESULT market whose three selections are all active and priced, and live events are dropped because their prices move every second. If two books disagree on a kickoff around midnight UTC, or you need fuzzy matching for less common names, the arbitrage bot guide shows a matcher with a time tolerance and similarity scores.
Step 3: Print the table, the best prices and deep links
// Overround: how far the implied probabilities add up past 100%.
const margin = (p) => `${((OUTCOMES.reduce((sum, o) => sum + 1 / p[o], 0) - 1) * 100).toFixed(1)}%`;
for (const { event, books } of await compare()) {
console.log(`\n${event.home} v ${event.away}, ${event.startTime}`);
const rows = Object.entries(books).map(([book, { prices }]) => ({ book, ...prices, margin: margin(prices) }));
const best = Object.fromEntries(OUTCOMES.map((o) => [o, Math.max(...rows.map((r) => r[o]))]));
const where = OUTCOMES.map((o) => `${o} @ ${rows.find((r) => r[o] === best[o]).book}`).join(", ");
console.table([...rows, { book: "best", ...best, margin: margin(best) }]);
console.log(` best prices: ${where}`);
for (const [book, { slug }] of Object.entries(books)) {
if (slug && SITES[book]) console.log(` ${book}: ${SITES[book]}${slug}`);
}
}The margin column is the bookmaker's overround: the implied probabilities (1 / odds) added up, minus 100%. The lower it is, the closer that book's prices are to fair. The best row combines the top price for each outcome, so its margin is always the lowest in the table; if it ever turns negative, the fixture is an arbitrage.
Odds math for developers covers implied probability, overround and removing the margin in more depth.
Where a book exposes the event's path on its own site as slug, prefixing the book's domain gives a deep link straight into the market: https://www.betfair.com for Betfair Sportsbook, as in the sample output. Add the domains of the other books you show to SITES.
How do you put it on a website?
- Keep the API key on the server; the docs are explicit that it must never appear in client-side code. Run
compare()on your server or in a scheduled job and serve the result as JSON to your front end. - Cache the computed table, not only the raw responses: 15 to 60 seconds suits pre-match prices.
- Render one row per fixture and one column per outcome, highlight the best cell, and link each price to that book's deep link.
- Show each book's margin as well. Readers use it as a fairness score, and it explains why one book keeps winning the best-price column.
- For live odds, don't poll
/live-eventspage by page: on PRO and above the live WebSocket pushes every live event of a sport about once a second, and the Stream API sends only what changed.
Can you add more bookmakers and markets?
Adding a bookmaker is one line in BOOKS: every one of the 60+ bookmakers serves the same paths under its own prefix, and the sportsbook API overview lists them all.
Other markets work the same way once the comparison key includes the line:
OVER_UNDER: compareOVERandUNDERat the sameline, reading it from the selection first and the market second.BOTH_TEAMS_TO_SCORE(YESandNO) andDRAW_NO_BET(HOMEandAWAY) slot in without a line.- Keep
periodin the key too, so a first-half market never meets a full-time one.
Prediction markets can sit in the same table. Polymarket and Kalshi events use the same markets and selections shape, with decimal odds derived from the market price plus lastPrice, bestBid and bestAsk (prices between 0 and 1) on every selection. For a price you can actually take, compute 1 / bestAsk; the bid-ask spread plays the part of the margin. The Polymarket API and Kalshi API pages describe their extra fields.
Frequently asked questions
Do I need a separate integration for each bookmaker?
No. Every bookmaker serves the same paths and the same JSON schema; only the prefix changes, for example /api/betfair-sb or /api/skybet, and /api/v3/bet365 for bet365. One API key covers all of them.
How do I match the same fixture across bookmakers?
Event IDs are per bookmaker, so match on normalized team names (lowercased, accents and "FC" removed, plus an alias table) and the kickoff date. Inside one competition the same two teams never meet twice on one day.
How often should an odds comparison page refresh?
Pre-match prices move slowly, so refresh every 15 to 60 seconds from a server-side cache. On STARTER the limit is 1 req/min per bookmaker, so refresh no faster than once a minute there. For live odds, use the WebSocket or the Stream API on PRO and above instead of polling.
What does the margin column mean?
It is the bookmaker's overround: the implied probabilities of all outcomes (1 / odds) added up, minus 100%. Lower means fairer prices. On the best-price row, a negative margin means the fixture is an arbitrage.
Can I compare Polymarket or Kalshi prices with sportsbook odds?
Yes. Their events use the same markets and selections, with decimal odds plus lastPrice, bestBid and bestAsk on every selection. Use 1 / bestAsk for a price you can actually take.
Keep reading
Build an Arbitrage Betting Bot Across Bookmakers
Find sure bets across bookmakers in Python: match the same fixture on every book, compare 1X2 and over/under prices, size the stakes, respect rate limits.
Implied Probability, Overround and No-Vig Odds Explained
Convert odds to implied probability, measure a bookmaker's overround and margin, remove the vig to get fair odds, and run the math in Python on live API data.