Skip to main content
Odds Comparison API: 30 Bookmakers, One Request
Back to Blog

Odds Comparison API: 30 Bookmakers, One Request

James Whitfield

James Whitfield

9 min read

Every odds comparison product starts from the same place: one event, many bookmakers, one screen. The hard part is never rendering the table. It is getting a set of prices that were all true at the same moment, for the same bet, from books whose names you spelled the way the API expects.

This guide covers how to pull a cross-book snapshot from the Odds-API.io v3 API in a single request, how to compare prices without inventing edges that do not exist, how to batch several events into one call, and how to keep the comparison fresh once it is on screen. Every response shown here is a real capture, not a mock.

Why one request per bookmaker does not work

The obvious design is a loop: for each book, call the API, merge the results. It breaks in two ways at once.

  • Request count. Ten books on twenty events is 200 calls for one page refresh. The free tier allows 100 requests per hour and paid plans 5,000, so a naive fan-out burns an hourly budget on a single screen.
  • Staleness skew. Calls do not land at the same instant. If the first book is fetched at 12:00:00 and the tenth at 12:00:04, you are comparing a four-second-old price against a fresh one. In play, that gap alone manufactures arbitrage that was never on the market.

The v3 API avoids both by making the bookmaker list a parameter instead of a dimension you iterate over.

The 30-bookmaker request

The /v3/odds endpoint takes an apiKey, an eventId and a bookmakers list. The bookmakers parameter is required and accepts up to 30 comma-separated names. One call, one snapshot, all books read from the same server-side state.

curl -G "https://api.odds-api.io/v3/odds" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "eventId=72221212" \
  --data-urlencode "bookmakers=Bet365,William Hill,Unibet,Betway,Coral,888Sport,Betfair Sportsbook,LeoVegas,10BET"

The response is an event object. Its bookmakers property is keyed by bookmaker name, and each value is an array of markets. Each market carries a name, an updatedAt timestamp and an odds array. Below is a real capture from Aston Villa vs Arsenal FC in the England Premier League, event 72221212, taken a few hours before kickoff and trimmed to two books and one market:

{
  "id": 72221212,
  "home": "Aston Villa",
  "away": "Arsenal FC",
  "date": "2026-08-31T19:00:00Z",
  "league": { "name": "England - Premier League", "slug": "england-premier-league" },
  "status": "pending",
  "bookmakers": {
    "Unibet": [
      {
        "name": "ML",
        "updatedAt": "2026-08-30T20:59:05.476Z",
        "odds": [{ "home": "6.75", "draw": "4.25", "away": "1.48" }]
      }
    ],
    "William Hill": [
      {
        "name": "ML",
        "updatedAt": "2026-08-31T03:50:06.427Z",
        "odds": [{ "home": "6.00", "draw": "4.00", "away": "1.53" }]
      }
    ]
  }
}

The market names are ML, Spread and Totals. There is no market called Match Winner or Over/Under, and guessing those names is the most common reason a first integration returns nothing. Prices arrive as strings, so parse them to floats before comparing.

Here is the full ML row from that same capture, all ten books that quoted the match, sorted by best price on each side:

bookmaker            home    draw    away    updatedAt
Unibet               6.75    4.25    1.48    2026-08-30T20:59:05Z
Betfair Sportsbook   6.50    4.33    1.53    2026-08-31T00:39:26Z
10BET                6.60    4.60    1.51    2026-08-31T03:34:58Z
LeoVegas             6.40    4.10    1.45    2026-08-30T21:36:52Z
Bet365               6.25    4.333   1.50    2026-08-31T03:49:16Z
888Sport             6.00    4.00    1.50    2026-08-31T03:31:17Z
Betway               6.00    4.333   1.50    2026-08-30T22:09:05Z
William Hill         6.00    4.00    1.53    2026-08-31T03:50:06Z
Coral                5.50    4.00    1.50    2026-08-31T02:14:46Z

best                 6.75    4.60    1.53

The spread across books is real money: 6.75 against 5.50 on the home side is a 22 percent difference in payout on the same bet. That single row is the entire commercial argument for odds comparison, and it took one HTTP call to produce.

Picking the best price without inventing edges

Best price is a maximum over a group, and the group definition is where integrations go wrong. Two prices are comparable only when the market name and the line both match. Moneyline is easy because it has no line. Totals and Spread are not.

From the same capture, the Totals market for this fixture:

Bet365               lines: 0.5 1.25 1.5 1.75 2 2.25 2.5 2.75 3 3.25 3.5 3.75 4 4.25 4.5 4.75 5.5 6.5 7.5
Unibet               lines: 2.25 2.75 2.5 3 3.25 3.5 2
Coral                lines: 2.5 3.5 4.5 1.5 0.5 5.5
Betfair Sportsbook   lines: 0.5 1.5 2.5 3.5 4.5 5.5 6.5
Bet365 (no latency)  lines: 2.75

Three things fall out of that. Books expose different sets of lines. The arrays are not in a consistent order, so odds[0] means something different in every book. And one feed here only quotes 2.75 at all, so it simply does not participate in a 2.5 comparison.

Group by market name and hdp first, then take the maximum inside each group. On the 2.5 goal line specifically, the same snapshot gives:

line 2.5             over    under
Betfair Sportsbook   1.80    1.95
10BET                1.77    2.02
LeoVegas             1.76    2.05
Unibet               1.76    2.05
Bet365               1.75    2.05
888Sport             1.75    2.00
Coral                1.70    1.95

best                 1.80    2.05
from collections import defaultdict

def best_prices(event, market_name):
    groups = defaultdict(dict)
    for book, markets in event["bookmakers"].items():
        for market in markets:
            if market["name"] != market_name:
                continue
            for row in market["odds"]:
                line = row.get("hdp")
                for side in ("home", "draw", "away", "over", "under"):
                    price = row.get(side)
                    if price is None:
                        continue
                    current = groups[line].get(side)
                    if current is None or float(price) > current[1]:
                        groups[line][side] = (book, float(price))
    return groups

for line, sides in best_prices(event, "Totals").items():
    print(line, sides)

The same rule applies with more force to Asian handicap, where the line moves as often as the price and a quarter-goal line settles across two legs. The mechanics of hdp, quarter lines and split stakes are covered in the Asian handicap API guide.

Several events in one call

A comparison site rarely shows one match. The /v3/odds/multi endpoint takes an eventIds list of up to 10 event IDs alongside the same bookmakers list of up to 30 names, and it counts as a single request against your quota.

curl -G "https://api.odds-api.io/v3/odds/multi" \
  --data-urlencode "apiKey=YOUR_KEY" \
  --data-urlencode "eventIds=72221212,72221244,72221248" \
  --data-urlencode "bookmakers=Bet365,Unibet,Coral"

The response is an array of the same event objects that /v3/odds returns, one per event. Two details matter in code. The array is not guaranteed to come back in the order you asked for, so index by the id field rather than by position. And the ceiling is 10 events per call, so a 40-match league page is four requests instead of four hundred.

Knowing which books your key can actually see

Bookmaker names are exact, case-sensitive strings. The /v3/bookmakers endpoint is the source of truth and returns each book with a name and an active flag. Passing a name that is not on that list fails the whole request rather than silently dropping one book:

{"error":"888sport is not a valid bookmaker, use /v3/bookmakers to get a list of valid bookmakers"}

That is a real response. The correct value is 888Sport, with a capital S. The same trap catches 10BET, Sbobet and every book with a regional suffix such as Unibet UK or Bwin DE, which are separate entries from their parent brands. Read the names from the endpoint, never from a brand logo.

Which of those 365 or so books your key can pull is a separate question, answered by /v3/bookmakers/selected. It returns the list attached to your account, which is what the WebSocket streams and what your comparison table is limited to.

curl -G "https://api.odds-api.io/v3/bookmakers/selected" \
  --data-urlencode "apiKey=YOUR_KEY"

Worth checking before you debug a missing book: a name can be valid globally and still be absent from your selection, and a book that is valid and selected can still be missing from one event simply because it does not price that match. In the capture above, three of the twelve books requested returned nothing for the fixture. Absence means no quote, not an error.

Keeping the table fresh

Once the comparison renders, the question becomes how often to refetch. Two things decide it.

First, updatedAt is a last-change timestamp, not a last-fetch timestamp. In the capture above, Unibet reads 20:59 and William Hill reads 03:50 on the following morning. That does not mean Unibet is stale in the sense of unfetched. It means Unibet has not moved its moneyline since 20:59. Treat updatedAt as market activity, and do not build a health check that flags an old timestamp as a broken feed.

Second, polling scales badly for anything live. The WebSocket endpoint pushes updates as they happen:

const params = new URLSearchParams({
  apiKey: "YOUR_API_KEY",
  markets: "ML,Totals",      // required for the odds channel
  channels: "odds",          // allowlist: you receive only what you list
  eventIds: "72221212"
});

const ws = new WebSocket(`wss://api.odds-api.io/v3/ws?${params}`);

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type !== "updated") return;
  // Replace, do not merge: msg.markets is the complete current set
  // for this event and bookmaker.
  store.set(`${msg.id}:${msg.bookie}`, msg.markets);
};

The markets parameter is required for the odds channel, and channels is an allowlist, so omitting odds from it stops odds delivery. The rule that matters most for a comparison table is the replacement semantics: each updated message carries the complete current market set for that event and bookmaker, not a delta. Suspended markets are dropped from the payload rather than flagged, so merging incoming data into your existing state leaves suspended prices on screen forever. Full details are in the WebSocket guide.

Gotchas worth writing down

  • Bookmaker names are exact and case-sensitive. One bad name returns a 400 for the entire request, not a partial response.
  • The bookmakers parameter caps at 30 names and eventIds at 10. Batch accordingly rather than discovering the limit in production.
  • Prices are strings. Compare floats, and do not assume a fixed number of decimals: the same capture contains 4.333 and 4.33 for the same draw price at different books.
  • Never compare across lines. Group by market name and hdp before taking a maximum, or your best-price scan will report edges that do not exist.
  • A book present in your selection can be absent from an event. Handle the missing key rather than assuming every requested book appears.
  • updatedAt is last change, not last fetch. An old timestamp is a quiet market.
  • Over WebSocket, replace the market set rather than merging it, otherwise suspended markets never disappear.

If you want to go one step past displaying prices, the same grouped-by-line structure is what an arbitrage check consumes: take the best price on each outcome within a group, sum the implied probabilities, and act when the total drops below 1. That is walked through end to end in the Python arbitrage bot tutorial.

The free tier is 100 requests per hour with no credit card, which is enough to build and test a full comparison page against live data before deciding on a plan.