Skip to content

Repository files navigation

rapidoddsapi

Official Node.js SDK for RapidOddsAPI. Bookmaker odds from 100+ books, live scores and player stats, over REST and WebSocket.

npm install rapidoddsapi

Requires Node 18 or newer. Written in TypeScript, ships its own types, and works the same from plain JavaScript. Get a key at rapidoddsapi.com; the free tier is 250 credits with no card.

What you get

  • 100+ bookmakers across Australia, the US and Europe, on 25 odds feeds — Bet365, Pinnacle, Sportsbet, TAB, Ladbrokes, DraftKings, BetMGM, Unibet, Bovada, Fanatics and more
  • Every market — head to head, totals, handicaps, team totals and player props, full time and by period
  • Live scores and player stats, broken down by period
  • A link straight to the game on the bookmaker's own site, so a price you find is one click from being placed
  • Streaming over WebSocket, pushed as each scrape finishes, reconnecting and re-subscribing on its own
  • Arbitrage and value bet finders built in, over any two-sided market
  • Typed throughout — every response, typed errors, automatic retry with backoff

Quickstart

import { RapidOddsAPI } from "rapidoddsapi";

const client = new RapidOddsAPI({ apiKey: "oa_your_api_key_here" });

const odds = await client.getOdds("AFL", ["head_to_head"], ["Sportsbet", "TAB", "Ladbrokes"]);

for (const entry of odds.games) {
  const game = entry.game;
  console.log(`${game.away_team} at ${game.home_team}`);
  for (const book of entry.bookmakers) {
    for (const market of book.markets) {
      for (const outcome of market.outcomes) {
        console.log(`  ${book.name.padEnd(12)} ${outcome.name.padEnd(20)} ${outcome.price}`);
      }
    }
  }
}

CommonJS works too:

const { RapidOddsAPI } = require("rapidoddsapi");

Streaming

Odds are pushed to you the moment a scrape cycle finishes, so you are never polling and never working off a stale price. Included on Pro and Elite at no extra cost.

for await (const update of client.streamOdds("AFL", ["head_to_head"], ["Sportsbet", "TAB"])) {
  for (const entry of update.data.games) {
    console.log(entry.game.home_team, update.credits_charged);
  }
}

That is the whole thing. The connection is kept alive, and if it drops (laptop sleeps, network cuts out, server restarts) the client reconnects with backoff and replays your subscription automatically. Leaving the loop closes the socket.

Live scores stream the same way:

for await (const update of client.streamResults("AFL", { status: "live" })) {
  for (const entry of update.data.games) {
    const { game, score } = entry;
    console.log(game.home_team, score?.home?.points, game.away_team, score?.away?.points);
  }
}

Tuning, if you need it:

client.streamOdds("AFL", ["head_to_head"], ["Sportsbet"], {
  reconnect: true,              // false to throw on the first drop
  maxReconnectAttempts: null,   // consecutive failures before giving up
  initialBackoff: 1000,         // milliseconds
  maxBackoff: 30000,
});

maxReconnectAttempts counts connections that never open. A connection that succeeds and later drops resets the count, so a stream running for weeks never exhausts its budget.

Finding bets

findArbitrage and findValueBets work on a response you already have, so they cost no extra credits.

import { findArbitrage, findValueBets } from "rapidoddsapi";

const odds = await client.getOdds(
  "MLB",
  ["head_to_head"],
  ["Bet365", "Sportsbet", "TAB", "Pinnacle", "DraftKings"],
);

for (const arb of findArbitrage(odds, { stake: 100 })) {
  console.log(`${arb.away_team} at ${arb.home_team}  +${arb.profit_percent.toFixed(2)}%`);
  for (const leg of arb.legs) {
    console.log(`  ${leg.team} ${leg.price} @ ${leg.bookmaker}  stake $${leg.stake}`);
  }
}
Toronto Blue Jays at Boston Red Sox  +4.85%
  Toronto Blue Jays 2.18 @ Bet365  stake $48.10
  Boston Red Sox 2.02 @ Sportsbet  stake $51.90

findValueBets strips the margin out of the odds to get a fair price, then reports every book paying more than that.

for (const bet of findValueBets(odds, { devig: "Pinnacle", minEdge: 1.0 })) {
  console.log(
    `+${bet.edge_percent.toFixed(1)}%  ${bet.selection} ${bet.price} ` +
      `@ ${bet.bookmaker}  (fair ${bet.fair_price.toFixed(2)})`,
  );
}

Settings

findArbitrage(odds, options)

Default
market "head_to_head" which market key to read
stake 100 total to split across the two legs
minProfit 0 percent. negative shows near misses

findValueBets(odds, options)

Default
devig "Pinnacle" where the fair price comes from, below
market "head_to_head" which market key to read
minEdge 1.0 percent
minBooks 4 books needed before an average is trusted
devig: "Pinnacle"                    // one book, excluded from its own results
devig: "all"                         // every book, fair odds averaged
devig: { Sportsbet: 0.7, TAB: 0.3 }  // only these, at these weights

Both work on any market with two sides: head to head, totals, handicaps, team totals and player props. Three-way markets throw ValidationError. Legs and value bets carry point, player_name and team_name where they apply.

Games are matched across books on team names plus a time window set by the sport, 1.8 hours for MLB because of doubleheaders and six for everything else.

Methods

Method Returns Credits
getOdds(sport, marketTypes, bookmakers) OddsResponse marketTypes x ceil(bookmakers / 5)
getResults(sport, { status, include, gameId, roundNumber, days }) ResultsResponse 1
listSports({ markets }) SportInfo[] 0
getSport(sport, { markets }) SportInfo 0
listResultsSports() SportInfo[] 0
getUsage() Usage 0
streamOdds(sport, marketTypes, bookmakers) async iterable of OddsUpdate same as getOdds, per push
streamResults(sport, { status, include, days }) async iterable of ResultsUpdate 1 per push
creditsUsed number

Every method returns a promise. Credits are only charged when games come back, so a query that matches nothing is free.

creditsUsed counts what this client has spent since you created it. It knows nothing about other processes or earlier runs. For the real balance, ask the server:

const usage = await client.getUsage();
console.log(`${usage.credits_remaining} of ${usage.credits_limit} left`);

On the free tier usage.resets is false: those credits are a one off allowance, not a monthly one.

Helpers: findArbitrage, findValueBets, groupGames, matchWindow, parseTime.

Sports

Pass the sport id as a string.

import { SPORTS, RESULTS_SPORTS } from "rapidoddsapi";

SPORTS;
// ['NFL', 'NBA', 'WNBA', 'MLB', 'NHL', 'AFL', 'NRL', 'EPL', 'MLS',
//  'WORLD_CUP', 'MENS_AO', 'MENS_RG', 'MENS_WIMBLEDON', 'MENS_USO']

RESULTS_SPORTS;
// ['AFL', 'MLB', 'WNBA', 'NRL']

Tennis reuses the team fields for player names, so home_team and away_team hold players on the four MENS_ ids. One slam is in season at a time.

Six more soccer leagues (La Liga, Serie A, Bundesliga, Ligue 1, Champions League, A-League) are accepted as ids but return no games yet. They are in UPCOMING_SPORTS rather than SPORTS.

Those arrays are a snapshot shipped with the package. To ask the API itself, which costs nothing:

await client.listSports();
// [{ id: 'NBA', name: 'NBA' }, { id: 'AFL', name: 'AFL' }, ...]

Market keys vary by sport, and getSport returns the ones a sport carries:

const afl = await client.getSport("AFL");
afl.markets?.game;
// ['alternate_lines', 'alternate_total_points', 'head_to_head', ...]

await client.getOdds("AFL", afl.markets!.game.slice(0, 1), ["Sportsbet"]);

The methods take plain strings, so an id the API adds before the next release still works. Sport, ResultsSport and BookmakerName are exported as union types if you want the narrower autocomplete.

The coverage page is the same information in a browser.

Bookmakers

Many brands share one odds feed and so quote identical prices. Requests name the feed, not the brand: "Ladbrokes" covers Neds, "Betmakers" covers the 26 brands running on it. Asking for a brand name returns nothing.

import { BOOKMAKERS, AU_BOOKMAKERS, US_BOOKMAKERS, EU_BOOKMAKERS } from "rapidoddsapi";

await client.getOdds("AFL", ["head_to_head"], [...AU_BOOKMAKERS]);

Every feed costs credits, so request the ones you need rather than all of them. Which feeds carry a given sport and market varies; the coverage page is the current picture.

Errors

import {
  RapidOddsAPI,
  AuthenticationError,
  InsufficientCreditsError,
} from "rapidoddsapi";

try {
  const odds = await client.getOdds("AFL", ["head_to_head"], ["Sportsbet"]);
} catch (error) {
  if (error instanceof InsufficientCreditsError) {
    console.log(`Out of credits, ${error.creditsRemaining} left`);
  } else if (error instanceof AuthenticationError) {
    console.log("Bad key");
  } else {
    throw error;
  }
}
Error When
AuthenticationError 401, key missing or not recognised
SubscriptionError 403, subscription not active
NotFoundError 404, unknown sport
ValidationError 400 or 422, bad parameter
RateLimitError 429, over 30 requests per second
InsufficientCreditsError 429, out of credits. Carries creditsRemaining
ServerError 5xx
NetworkError timeout, DNS failure, connection reset
StreamAuthError stream rejected: bad key, or plan without WebSocket
StreamError stream failed for another reason

All extend RapidOddsAPIError. The two 429s share a QuotaError parent, so you can catch either separately or both at once.

Requests are retried three times with backoff on 5xx, rate limits and network failures. Insufficient credits is never retried, since retrying cannot help.

Timestamps

Timestamps are naive UTC strings with no offset, like 2026-07-23T09:30:00. JavaScript reads a date-time without an offset as local time, so new Date(commence_time) is wrong by however many hours you are from UTC. Use parseTime.

import { parseTime } from "rapidoddsapi";

const start = parseTime(entry.game.commence_time); // a Date, correctly in UTC

The odds and results APIs can disagree on a game's start time by several minutes, since one comes from bookmaker feeds and the other from the official league feed. Don't join them on an exact timestamp. groupGames matches on teams plus a time window instead.

Settling from results

score.totals.full_time is null until a game is CONCLUDED, and each other named total is null until the periods it covers have finished. That is deliberate, so a live scoreline can never be mistaken for a final one. For a live running total, sum by_period.

const totals = entry.score?.totals;

if (totals?.full_time != null) {
  settle({ totalPoints: totals.full_time.points });
} else {
  const running = (totals?.by_period ?? []).reduce((sum, period) => sum + period.points, 0);
}

Do not hardcode how many periods a half is: half_time waits for period 2 in a sport played in quarters but only period 1 in a sport played in halves, and MLB carries first_5_innings instead. Let the null tell you.

Coming from the Python SDK

Same behaviour throughout, including the devig maths, market grouping and per-sport match windows. Three things read differently:

  • One client class. Node has no synchronous HTTP, so RapidOddsAPI is what AsyncRapidOddsAPI is in Python and every method is awaited
  • Keyword arguments become an options object, and the options we define are camelCase. Response fields are untouched, so anything snake_case came off the wire
  • Durations are milliseconds. initialBackoff: 1000, not 1.0

Use cases

  • Arbitrage and positive EV scanners, using the helpers above
  • Odds comparison screens and line-shopping tools
  • Line movement tracking and alerting off the stream
  • Automatic bet settlement from the results API
  • Bonus bet conversions
  • Model backtesting against live prices

Worked examples for each are in the guides.

Links

License

MIT

About

Official Node.js SDK for RapidOddsAPI - bookmaker odds, live scores and player stats

Topics

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages