Bet Better BET BETTER PRO

R at Bet Better › R for Betting-Market Data

Chapter 6. Using the free JSON feed from R

20 October 2026. Edward Glush. Code run in R 4.4.1 against the live open data on that day.

The earlier chapters used static files. This one reads a live feed. The feed publishes a model's win probabilities and fair odds for upcoming games, as JSON, with no key. We read it with jsonlite and look at its shape.

Data and licence. Feed: https://betbetter.world/nfl/picks?format=json (and the same path for other leagues), documented at betbetter.world/api. Licence CC BY 4.0 with attribution. The numbers are model estimates for research, not a guarantee and not advice. Some rows are locked for members until kick-off.

6.1 What the feed is

Each row in the feed is one model estimate for one outcome: how likely the model thinks it is, and the fair odds that go with that chance. Fair odds here are simply 100 divided by the probability. They are the model's number, not a bookmaker's price. The feed is a forecast, and this chapter treats it as data to study, not as a list of bets.

6.2 Read it

jsonlite::fromJSON() reads a URL straight into R. Objects become lists and arrays of records become data frames.

library(jsonlite)
u <- "https://betbetter.world/nfl/picks?format=json"
feed <- fromJSON(u)
names(feed)
feed$sport
feed$licence
##  [1] "site"        "page"        "sport"       "type"       
##  [5] "updatedUtc"  "licence"     "attribution" "docs"       
##  [9] "disclaimer"  "count"       "picks"       "lockedCount"
## [13] "accessNote" 
## [1] "NFL"
## [1] "CC BY 4.0 — free to use with attribution to Bet Better (https://betbetter.world)"

The top level holds facts about the feed: the time it was built (updatedUtc), the licence, a link to the docs, a disclaimer, and two counts. The rows are in picks.

feed$count
feed$lockedCount
class(feed$picks)
length(feed$picks)
## [1] 0
## [1] 0
## [1] "list"
## [1] 0

6.3 Handle an empty feed

When this chapter was run, the NFL feed was empty. That happens between game weeks: the feed only lists upcoming games. When there are no rows, fromJSON() returns an empty list, not an empty data frame. Code that expects columns will then fail. A small wrapper keeps the shape the same every time.

get_feed <- function(league) {
  x <- fromJSON(sprintf("https://betbetter.world/%s/picks?format=json", league))
  rows <- if (length(x$picks) == 0) data.frame() else as.data.frame(x$picks)
  attr(rows, "updatedUtc") <- x$updatedUtc
  rows
}
nfl <- get_feed("nfl")
nrow(nfl)
## [1] 0

The attribute keeps the build time with the rows. That matters later, when you save pulls and want to know which one is which.

Every league uses the same path and the same columns. To show the structure with real rows, we read the NHL feed, which had games listed on the day.

nhl <- get_feed("nhl")
nrow(nhl)
sapply(nhl, class)
## [1] 36
##                game         gameTimeUtc              market 
##         "character"         "character"         "character" 
##           selection                line   winProbabilityPct 
##         "character"           "numeric"           "numeric" 
##    probabilityLabel modelProbabilityPct            fairOdds 
##         "character"           "numeric"           "numeric" 
##          confidence             verdict              locked 
##         "character"         "character"           "logical" 
##        unlocksAtUtc         lockedPicks 
##         "character"           "integer" 

6.4 The columns

  • game and gameTimeUtc: the fixture (away @ home) and its start time in UTC.
  • market, selection, line: what is being rated. The line is the handicap or total, or NA.
  • modelProbabilityPct: the model's chance the selection lands, 0 to 100. winProbabilityPct carries the same number, with probabilityLabel saying whose number it is.
  • fairOdds: decimal odds implied by that chance.
  • confidence: a word band for the chance, and verdict: one plain sentence.
  • locked, unlocksAtUtc, lockedPicks: whether the row is for members, when it opens, and how many locked estimates share the row.

6.5 Locked rows

Each league's free rows are open to everyone. Other rows are for members. A locked row still shows the game, the start time and the market, but its selection, chance and odds are empty.

table(nhl$locked)
sum(is.na(subset(nhl, locked)$modelProbabilityPct))
## 
## FALSE  TRUE 
##    11    25 
## [1] 25

Every one of the 25 locked rows has an empty chance. That is by design, and your code should expect it.

So filter on locked before you summarise, or the empty values will turn every mean into NA. lockedCount at the top level can be a little higher than the number of locked rows, because several locked estimates on the same market share one row.

6.6 Summarise the open rows

table(nhl$market)
open <- subset(nhl, !locked)
summary(open$modelProbabilityPct)
summary(open$fairOdds)
## 
## 3-Way Moneyline           Goals       Moneyline       Puck Line 
##              11               3               5              11 
##     Total Goals 
##               6 
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   31.10   39.65   59.90   54.02   67.45   72.30 
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
##   1.380   1.480   1.670   2.036   2.560   3.220 

The table shows which markets the feed covers for this league on this day. For hockey that is the match winner with and without the draw, the puck line, total goals and a player goals market. Other leagues have their own market names, so do not hard-code a list. Read market and group by it.

The chance runs from about 31 to 72 per cent across the open rows, and the fair odds from 1.38 to 3.22. These are summaries of a forecast, not a record of how good it is. You cannot tell from one pull whether the numbers are any good. Only a long run of saved rows, scored against results, can tell you that.

Check the documented rule that fair odds are 100 divided by the chance, rounded to two places:

all.equal(open$fairOdds, round(100 / open$modelProbabilityPct, 2))
## [1] TRUE

It holds. That is a useful habit with any feed: check one documented rule in code, so you notice at once if the format ever changes.

Turn the start time into a real time. The feed uses ISO 8601 with a trailing Z for UTC.

open$gameTimeUtc <- as.POSIXct(open$gameTimeUtc, format = "%Y-%m-%dT%H:%M:%S", tz = "UTC")
range(open$gameTimeUtc)
## [1] "2026-10-20 23:00:00 UTC" "2026-10-21 02:30:00 UTC"

If you plan to join the feed to other data, keep everything in UTC until the last step. Game times cross midnight in most time zones, so a date taken from local time can put a game on the wrong day.

6.7 Using it well

  • Save each pull with its updatedUtc. The feed changes through the day and old rows leave it at kick-off, so the only history you have is the history you keep.
  • Compare the model's chance with a bookmaker's fair chance (chapter 2) to see where they differ. A difference is a question to study, not an instruction to bet.
  • Score saved rows against results later, the way chapter 4 scores prices. That is the only fair test of any forecast.
  • Be polite to the server. Once an hour is plenty; the data does not change faster than that for most leagues.
  • Credit the source as the licence asks: Bet Better, with a link.

The betbetter R package wraps this feed and others (predicted scores, power rankings). See the R at Bet Better page for install notes.

Try it yourself

  • Loop get_feed() over several leagues and bind the results with a league column. Which leagues had rows today?
  • Write the open rows to a dated CSV each time you run the script, then read them all back with do.call(rbind, lapply(files, read.csv)).
  • Plot modelProbabilityPct against fairOdds to see the 100 / p curve.

Data licensed CC BY 4.0, free to reuse with a credit. Model estimates, not advice. 18+. If gambling is causing you harm: gamblinghelponline.org.au.

Get tomorrow's free pick by email

Free. One email a day, tops. Unsubscribe in one tap.

18+ only. Please gamble responsibly.