You can fetch and compare betting odds in R without leaving your usual tools. oddsapiio is the official R client for the Odds-API.io v3 API and is available on CRAN. The functions used below return plain data frames.

This guide compares the match odds for one Premier League fixture across eight UK bookmakers. It works out each bookmaker's margin, the best price per outcome, a fair price with the margin removed, and a chart. Then it checks today's margins against closing odds from recent matches.

Every block below was run against the live API on 14 November 2026 with oddsapiio 0.2.1, ggplot2 4.0.4 and R 4.6.1, and the outputs are from that run. Prices move, so your numbers will differ.

What you need

  • R 4.1 or newer (ggplot2 4 needs it; oddsapiio alone runs on 4.0).
  • An Odds-API.io key from a paid plan.
  • The bookmakers you request selected on your key. The API covers 450+ bookmakers, and your plan sets how many you can select.

Install oddsapiio and set your key

Install the package and ggplot2 from CRAN:

install.packages(c("oddsapiio", "ggplot2")) 

The package reads the key from the ODDS_API_KEY environment variable. Put it in your user .Renviron file (if usethis is installed, usethis::edit_r_environ() opens it) as ODDS_API_KEY=your-key, then restart R. That keeps the key out of your scripts and out of git. Check that R can see it without printing it:

library(oddsapiio) nzchar(Sys.getenv("ODDS_API_KEY")) 
[1] TRUE 

Find an event

oa_events() lists fixtures for a sport, and a league slug narrows it down. Status pending means not started yet.

events <- oa_events("football", league = "england-premier-league", status = "pending") stopifnot(nrow(events) > 0) events <- events[order(events$date), ] print(head(events[, c("id", "home", "away", "date")], 6), row.names = FALSE) 
 id home away date 72221406 Manchester City Fulham FC 2026-11-21T12:30:00Z 72221392 Aston Villa Sunderland AFC 2026-11-21T15:00:00Z 72221394 AFC Bournemouth Nottingham Forest 2026-11-21T15:00:00Z 72221398 Chelsea FC Leeds United 2026-11-21T15:00:00Z 72221400 Coventry City Crystal Palace 2026-11-21T15:00:00Z 72221410 Tottenham Hotspur Ipswich Town 2026-11-21T15:00:00Z 

The id column is what the odds functions take. Market names come from the catalogue. oa_markets() needs no key and returns every market the API serves for a sport, with its shape. The same list is on the markets reference, for example football markets.

markets <- oa_markets("football") print(head(markets[, c("name", "shape", "period")], 5), row.names = FALSE) 
 name shape period ML moneyline match Draw No Bet moneyline match Double Chance outcomes match Spread line match Totals line match 

ML is the match result market: home, draw and away in football.

Pull odds from several bookmakers

oa_odds() takes one event id, up to 30 bookmakers and an optional list of markets. It returns one row per bookmaker and price line, with prices as strings. The block below stops unless exactly one fixture matched, keeps the eight books requested, converts the prices to numbers, drops any book without a valid price for all three outcomes, prints the books that were dropped (none here) and reshapes the table to one row per bookmaker and outcome. Change the team names to a fixture from your own events table, since the list only covers the next 14 days by default.

books <- c("Bet365", "Unibet", "William Hill", "Paddy Power", "Skybet", "Ladbrokes", "Coral", "BetVictor") event_id <- events$id[grepl("Newcastle", events$home) & grepl("Arsenal", events$away)] stopifnot(length(event_id) == 1) odds <- oa_odds(event_id, bookmakers = books, markets = "ML") odds <- subset(odds, bookmaker %in% books) stopifnot(nrow(odds) > 0) ml <- odds[, c("bookmaker", "home", "draw", "away")] ml[-1] <- lapply(ml[-1], as.numeric) ml <- subset(ml, home > 1 & draw > 1 & away > 1) setdiff(books, ml$bookmaker) long <- data.frame( bookmaker = rep(ml$bookmaker, 3), outcome = rep(c("home", "draw", "away"), each = nrow(ml)), price = c(ml$home, ml$draw, ml$away) ) nrow(long) head(long, 4) 
character(0) [1] 24 bookmaker outcome price 1 Bet365 home 5.75 2 BetVictor home 5.50 3 Coral home 5.25 4 Ladbrokes home 5.25 

Eight bookmakers times three outcomes gives 24 rows. The code assumes three-outcome football match odds. A two-way sport such as tennis needs the draw column removed from the reshape, the outcome order and the margin sum. Bookmaker names must match oa_bookmakers() exactly, including case.

Implied probability and margin per bookmaker

The implied probability of a decimal price is one divided by the price. A bookmaker's three implied probabilities add up to more than 100%, and the excess is its margin (also called the overround or vig).

long$implied <- 1 / long$price margin <- aggregate(implied ~ bookmaker, data = long, FUN = sum) margin$margin_pct <- round(100 * (margin$implied - 1), 2) print(margin[order(margin$margin_pct), c("bookmaker", "margin_pct")], row.names = FALSE) 
 bookmaker margin_pct Unibet 4.90 Coral 5.36 Ladbrokes 5.36 Bet365 6.43 BetVictor 7.09 Paddy Power 7.50 William Hill 7.50 Skybet 7.74 

On this match, Unibet's book adds up to 104.9% and Skybet's to 107.7%. The margin covers all three outcomes at once, so a book with a high margin can still have the top price on one of them. William Hill, at 7.5%, shares the best away price.

Best price per outcome

The best price for each outcome is the highest one on offer. Several books often share it, so the block lists all of them.

best <- aggregate(price ~ outcome, data = long, FUN = max) best$bookmakers <- mapply(function(o, p) { paste(long$bookmaker[long$outcome == o & long$price == p], collapse = ", ") }, best$outcome, best$price) print(best, row.names = FALSE) 
 outcome price bookmakers away 1.60 Coral, Ladbrokes, William Hill draw 4.20 Bet365, Coral, Ladbrokes, Paddy Power, Unibet home 5.75 Bet365, Unibet 

Fair price with the margin removed

To get a fair price, remove each bookmaker's margin first. The simplest way is proportional: divide each implied probability by that bookmaker's total, so the three outcomes add up to 100%. Then average the no-vig probabilities across books and turn the average back into odds. The no-vig calculator does the same for a single set of prices if you want to check a row by hand.

long$book_total <- ave(long$implied, long$bookmaker, FUN = sum) long$no_vig <- long$implied / long$book_total fair <- aggregate(no_vig ~ outcome, data = long, FUN = mean) fair$fair_price <- 1 / fair$no_vig comparison <- merge(best, fair[, c("outcome", "fair_price")]) comparison$best_vs_fair_pct <- round(100 * (comparison$price / comparison$fair_price - 1), 1) comparison$fair_price <- round(comparison$fair_price, 2) comparison <- comparison[match(c("home", "draw", "away"), comparison$outcome), ] print(comparison[, c("outcome", "price", "fair_price", "best_vs_fair_pct")], row.names = FALSE) 
 outcome price fair_price best_vs_fair_pct home 5.75 5.68 1.3 draw 4.20 4.40 -4.6 away 1.60 1.68 -4.5 

The best home price, 5.75, sits 1.3% above the fair estimate. The best draw and away prices are below it, by 4.6% and 4.5%. This fair price comes from eight UK retail books with the proportional method, so treat it as a rough estimate. Other de-vig methods, such as the power or Shin method, move more of the margin onto long shots and give a slightly different answer.

To skip the arithmetic, oa_odds_summary() returns best, average, median and fair prices per outcome in one call. It is available on Growth plans and above.

Chart the prices with ggplot2

Each panel shows one outcome, with a dot per bookmaker and a dashed line at the fair price.

library(ggplot2) long$outcome <- factor(long$outcome, levels = c("home", "draw", "away")) fair$outcome <- factor(fair$outcome, levels = levels(long$outcome)) p <- ggplot(long, aes(x = price, y = reorder(bookmaker, price))) + geom_vline(data = fair, aes(xintercept = fair_price), linetype = "dashed", colour = "grey50") + geom_point(size = 2.5, colour = "#2563eb") + facet_wrap(~ outcome, scales = "free_x") + labs(title = "Newcastle v Arsenal, match odds by bookmaker", subtitle = "Dashed line: fair price from the no-vig average", x = "Decimal odds", y = NULL) + theme_minimal(base_size = 12) ggsave("odds-by-bookmaker.png", p, width = 8, height = 4.5, dpi = 150, bg = "white") 

Match odds for Newcastle v Arsenal at eight bookmakers, one panel per outcome, with a dashed line at the no-vig fair price

Unibet and Bet365 lead on Newcastle. On Arsenal, Coral, Ladbrokes and William Hill share the top price, and Bet365 is lowest at 1.533.

Check margins against closing lines

One match is a small sample. Closing odds, the last prices before kick-off, show what a bookmaker's margin usually looks like. oa_closing_lines() returns them for up to 10 leagues over a window of up to 366 days, in the same row format as oa_odds(). Each response holds at most 100 events, so page through longer windows with limit and skip.

cl <- oa_closing_lines("football", "england-premier-league", from = "2026-10-01T00:00:00Z", to = "2026-11-10T23:59:59Z", markets = "ML", bookmakers = books) stopifnot(nrow(cl) > 0) cl[c("home", "draw", "away")] <- lapply(cl[c("home", "draw", "away")], as.numeric) cl <- subset(cl, home > 1 & draw > 1 & away > 1) cl$margin_pct <- 100 * (1 / cl$home + 1 / cl$draw + 1 / cl$away - 1) length(unique(cl$eventId)) closing <- aggregate(margin_pct ~ bookmaker, data = cl, FUN = function(x) round(median(x), 2)) names(closing)[2] <- "closing_margin_pct" counts <- aggregate(eventId ~ bookmaker, data = cl, FUN = function(x) length(unique(x))) names(counts)[2] <- "matches" print(Reduce(merge, list(margin[, c("bookmaker", "margin_pct")], closing, counts)), row.names = FALSE) 
[1] 50 bookmaker margin_pct closing_margin_pct matches Bet365 6.43 5.34 50 BetVictor 7.09 7.44 43 Coral 5.36 6.02 50 Ladbrokes 5.36 6.84 40 Paddy Power 7.50 6.98 50 Skybet 7.74 6.51 50 Unibet 4.90 5.19 50 William Hill 7.50 7.33 40 

The response holds closing prices for 50 Premier League matches between 1 October and 10 November. The closing_margin_pct column is the median match-odds margin at kick-off for each book, and matches counts the matches with a complete closing price from that book. Unibet has the lowest margin in both columns. Ladbrokes' margin today is 5.36%, below its 6.84% median closing margin in this sample, and Bet365's is above its median close. Margins can change as kick-off gets closer, so compare a book with its own history before you read much into one number. The historical odds API page covers what is stored, and the historical guide lists the parameters and limits.

Next steps

  • Add "Totals" or "Spread" to markets. Those rows carry a hdp column for the line and their own price columns (over and under on Totals), so reshape each market separately and group by market, line and bookmaker.
  • Use oa_multi_odds() to pull up to 10 events in one request, then run the same margin code with the event id added to each group.
  • Fetch final scores with oa_historical_events() in windows of up to 31 days and join its id column to the closing lines' eventId to grade prices against results.

The package README lists every function, the SDK guide covers the R, Python and Node.js clients side by side, and the odds guide explains the response format. If you work in Python, Oddsportal API Alternative: Football Odds History in Python does a similar closing-odds pull with pandas.

I run Odds-API.io, so I'm biased toward the API used here. This post was written with AI assistance and every code block was run and checked by hand. Originally published on the Odds-API.io blog.