oddsrelay

The OddsRelay R client. One function for each GET route of the OddsRelay API, which serves bookmaker and exchange odds for the UK & Ireland as JSON. Replies come back as lists and data frames.

Install

install.packages("oddsrelay")

# or the development version
# install.packages("pak")
pak::pak("oddsrelay/oddsrelay-r")

First call

Sign up at app.oddsrelay.io and copy your key from the dashboard. Put it in your .Renviron (open it with file.edit("~/.Renviron")), never in a script:

ODDSRELAY_KEY=your key

Restart R, then:

library(oddsrelay)

board <- or_odds("standard", region = "uk", sports = "soccer", markets = "h2h") # soccer is the API's key for football
board$data[, c("commence_time", "home_team", "away_team")]

attr(board, "tokens") # what the call cost and what is left

No key yet? These answer without one:

or_status()
names(or_coverage(region = "uk")$products)

Functions

Function Route What it returns
or_odds(type, ...) GET /v2/odds/{type} Matched odds for one board: standard, 2up, dutching, each-way, extra-place or bog
or_raw(...) GET /v2/odds/raw Bookmaker and exchange prices by event, unpaired, a page at a time
or_event(id, ...) GET /v2/odds/event/{id} One event’s raw prices
or_sports(...) GET /v2/sports Sport and competition keys
or_events(sport, ...) GET /v2/events Upcoming events for one sport key
or_bookmakers(...) GET /v2/bookmakers The bookmakers and exchanges you can filter on, with their keys
or_regions() GET /v2/regions The region codes open to your key
or_coverage(...) GET /v2/coverage Which bookmakers and sports each product carries (keyless; more detail with a key)
or_status(...) GET /v2/status Whether each product is serving (keyless)
or_health() GET /v2/health When each board was last processed, and whether any is behind
or_usage() GET /v2/usage Tokens used and left, and what your key may read
or_pricing() GET /v2/pricing The token rate card
or_openapi() GET /v2/openapi.json The API description (keyless)
or_healthz() GET /healthz A plain-text liveness check (keyless)

Each returns the decoded body with every field kept, including fields the API adds after this release. Arrays of records become data frames, so board$data above is one. Pass format = "bare" where a route takes it to get the data frame alone.

Filters

Every query parameter in the API reference is a named argument. The API’s camelCase and R’s snake_case both work, so odds_format = "decimal" and oddsFormat = "decimal" send the same thing. A vector becomes one comma-separated value, TRUE and FALSE become true and false, a date-time becomes a UTC timestamp, and NULL is left out:

now <- Sys.time()
or_odds(
  "each-way",
  region = "uk",
  sports = "horse_racing",
  commence_time_from = now,
  commence_time_to = now + 6 * 3600
)

The API checks every filter itself. An unknown name or value signals oddsrelay_bad_request or a more specific class such as oddsrelay_unknown_sport, and where the API lists the accepted values, e$error$valid holds them.

Price a call before you make it

quote = TRUE asks what a call would cost instead of making it. A quote spends no tokens:

quote <- or_odds("standard", region = "uk", quote = TRUE)
quote$cost
quote$remaining

Polling: spend tokens only on changes

For each URL it has fetched in this session, the package keeps the reply’s ETag and sends it back as If-None-Match. When nothing has changed the API answers 304, which spends no tokens, and you get back the body you already had with attr(x, "not_modified") set to TRUE:

repeat {
  board <- or_odds("standard", region = "uk", sports = "soccer")
  if (!attr(board, "not_modified")) update_my_screen(board$data)
  Sys.sleep(poll_seconds)
}

Paging

or_raw(), or_sports() and or_events() return a page at a time. Pass the next page’s cursor back as cursor:

cursor <- NULL
repeat {
  page <- or_raw(region = "uk", sports = "tennis", cursor = cursor)
  handle(page$data)
  cursor <- page$meta$next_cursor
  if (is.null(cursor)) break
}

Errors

A refusal signals a condition of class oddsrelay_error. In front of that sit oddsrelay_<code>, from the error code in the reply, and oddsrelay_http_<status>. Each condition carries status, code, request_id, error (the whole error object), tokens and rate_limit.

Status Example classes
400 oddsrelay_bad_request, oddsrelay_unknown_sport, oddsrelay_http_400
401 oddsrelay_missing_api_key, oddsrelay_invalid_api_key, oddsrelay_http_401
402 oddsrelay_insufficient_tokens, with cost, remaining and resets_at
403 oddsrelay_sport_not_in_plan, oddsrelay_http_403
404 oddsrelay_unknown_event, oddsrelay_http_404
429 oddsrelay_too_many_requests, with retry_after, once the package’s own retries are used up
5xx oddsrelay_http_500, oddsrelay_http_502, oddsrelay_http_503

Branch on the class or e$code, never on the message, which may change. Quote request_id when you contact us.

tryCatch(
  or_odds("dutching", region = "uk"),
  oddsrelay_insufficient_tokens = function(e) {
    message("needs ", e$cost, " tokens, ", e$remaining, " left until ", e$resets_at)
  }
)

A 429 is retried for you: the package says how long it will wait, waits the Retry-After seconds and tries again, three requests in all. Network failures are left as ‘httr2’ signals them, so a long-running loop also handles httr2_failure.

Tokens and rate limits

Every result carries the reply’s headers as attributes:

A header the reply did not carry reads as NA. Printing a result shows the body and one line of tokens, not the attributes; unclass() gives the plain list.

Your key

The key travels only in the Authorization header. The package never puts it in a URL, and it is redacted when a request is printed and removed from any condition message. A filter named like a key (key, api_key, token or any name starting with a dot) is refused before anything is sent, so a misspelt .key never reaches a URL. Spaces and newlines around the key, common when it is read from a file, are dropped.

Versions

The Config/oddsrelay/contract-version field of DESCRIPTION names the version of the API description these functions follow:

utils::packageDescription("oddsrelay")[["Config/oddsrelay/contract-version"]]

The API only adds to /v2, so new fields and new error codes reach you without an upgrade.

Licence

MIT. See LICENSE.md.