Skip to contents

Package Overview

oddsapiR is an R client for The Odds API v4, providing clean, tidy sports betting odds, scores, events, and historical odds snapshots. Every exported wrapper is prefixed toa_ and returns an oddsapiR_data tibble.

When this guide differs from current repository docs, treat CONTRIBUTING.md and the current test implementations as authoritative.

  • Version: 1.0.0 (dev)
  • R Requirement: >= 4.0.0
  • License: MIT
  • Branch: main is the default branch and release branch.
  • HTTP stack: httr2 (migrated from httr in 1.0.0).

Build & Development Commands

# Regenerate roxygen documentation + NAMESPACE
devtools::document()

# Run all tests (live; needs ODDS_API_KEY set)
devtools::test()

# Run a specific test file
testthat::test_file("tests/testthat/test-toa_event_odds.R")

# Full R CMD check
devtools::check()

# Re-render README from README.Rmd
devtools::build_readme()

# Build pkgdown site locally
pkgdown::build_site()

# Install locally (prefer pak)
pak::local_install()

Project Structure

R/
  utils.R                       # HTTP layer + class machinery:
                                #   toa_api_request(), toa_api_call(), toa_api_headers(),
                                #   toa_quota(), make_toa_data(), print.oddsapiR_data(),
                                #   .toa_store_quota(), .oddsapiR env
  toa_api_key.R                 # register_toa: toa_key(), has_toa_key(), check_toa_key()
  toa_sports.R                  # toa_sports()            -> /v4/sports
  toa_sports_odds.R             # toa_sports_odds()       -> /v4/sports/{sport}/odds
  toa_sports_scores.R           # toa_sports_scores()     -> /v4/sports/{sport}/scores
  toa_sports_events.R           # toa_sports_events()     -> /v4/sports/{sport}/events
  toa_event_odds.R              # toa_event_odds()        -> /v4/sports/{sport}/events/{eventId}/odds
  toa_event_markets.R           # toa_event_markets()     -> /v4/sports/{sport}/events/{eventId}/markets
  toa_sports_participants.R     # toa_sports_participants()-> /v4/sports/{sport}/participants
  toa_sports_odds_history.R     # toa_sports_odds_history()-> /v4/historical/sports/{sport}/odds
  toa_sports_events_history.R   # toa_sports_events_history()-> /v4/historical/sports/{sport}/events
  toa_event_odds_history.R      # toa_event_odds_history()-> /v4/historical/sports/{sport}/events/{eventId}/odds
  toa_requests.R                # toa_requests()          -> quota usage headers
  data.R                        # toa_sports_keys lookup data
tests/testthat/                 # One test file per exported function
man/                            # Auto-generated roxygen docs (DO NOT EDIT)
NAMESPACE                       # Auto-generated by roxygen2 (DO NOT EDIT)

Key Coding Conventions

Function Naming

All public functions are prefixed toa_ (The Odds API). New endpoint wrappers follow the URL shape: toa_<resource>[_<subresource>][_history].

HTTP Layer (httr2)

All requests go through the shared helpers in utils.R. Never call httr2/httr directly from a wrapper. Wrappers build a base URL + a named query list and hand both to toa_api_call():

toa_function <- function(sport_key, regions = 'us', markets = 'h2h', date_format = 'iso'){
  base_url <- glue::glue('https://api.the-odds-api.com/v4/sports/{sport_key}/odds')
  query_params <- list(
    apiKey      = as.character(toa_key()),
    regions     = regions,
    markets     = markets,
    dateFormat  = date_format
  )

  odds <- data.frame()   # initialize return var BEFORE tryCatch (see below)

  tryCatch(
    expr = {
      odds <- toa_api_call(base_url, query = query_params) %>%
        # ... parse ...
        make_toa_data("Sports Odds data from the-odds-api.com", Sys.time())
    },
    error = function(e) {
      cli::cli_alert_danger("{Sys.time()}: Invalid arguments or no data available for {sport_key}!")
      cli::cli_alert_danger("Error:\n{e}")
    },
    warning = function(w) {
      cli::cli_alert_warning("{Sys.time()}: Warning:\n{w}")
    },
    finally = {}
  )
  return(odds)
}
  • toa_api_call(url, query) – builds the request, performs it, returns the parsed JSON.
  • toa_api_headers(url, query) – same, but returns the requests_remaining/requests_used quota data.frame (used by toa_requests()).
  • NULL query values are dropped automatically by httr2::req_url_query(), so optional parameters (bookmakers = NULL, event_ids = NULL, days_from = NULL) can be threaded through unconditionally – never build the query list conditionally.
  • Booleans must be lowercased strings: includeRotationNumbers = if (isTRUE(x)) "true" else NULL.

Return-Value Initialization (CRITICAL)

Every wrapper assigns its return variable inside tryCatch but returns it unconditionally. Initialize that variable to an empty value (data.frame()) before the tryCatch, otherwise an API error leaves the variable unbound and return(<var>) throws object '<var>' not found instead of the intended cli message + empty fallback. This applies to every return name (odds, events, scores, participants, etc.).

Usage-Quota Header Capture

The Odds API returns three usage headers on every response: x-requests-remaining, x-requests-used, x-requests-last. toa_api_request() caches these in the package-local .oddsapiR environment via .toa_store_quota(). They are then:

  1. Attached as attributes (oddsapiR_requests_remaining/_used/_last) to every returned tibble by make_toa_data() (the dplyr/tidyr transforms strip attributes from the intermediate parse, so they are attached at the end, not on the raw response).
  2. Echoed when an oddsapiR_data tibble is printed (print.oddsapiR_data).
  3. Exposed via the exported toa_quota() accessor (last call’s usage this session).

When adding a wrapper, do nothing special – routing through toa_api_call() + make_toa_data() wires the quota capture automatically.

Data Processing Pipeline

Odds payloads nest bookmakers[] -> markets[] -> outcomes[]. Unnest to a long tibble and rename the ambiguous key/last_update fields at each level:

raw %>%
  tidyr::unnest("bookmakers") %>%
  dplyr::rename("bookmaker_key" = "key", "bookmaker" = "title",
                "bookmaker_last_update" = "last_update") %>%   # omit for the current
                                                               # event-odds endpoint, which
                                                               # has no bookmaker last_update
  tidyr::unnest("markets") %>%
  dplyr::rename("market_key" = "key", "market_last_update" = "last_update") %>%
  tidyr::unnest("outcomes", names_sep = "_")                   # -> outcomes_name/_price/_point

For single-event endpoints the response is one object; build a 1-row tibble with a bookmakers = list(raw$bookmakers) list-column (requires tibble::tibble) before unnesting. Historical endpoints wrap the payload in a {timestamp, previous_timestamp, next_timestamp, data} envelope – surface the three snapshot timestamps as leading columns (.before = 1).

make_toa_data() sets the class to c("oddsapiR_data","tbl_df","tbl","data.table","data.frame") and attaches the oddsapiR_timestamp / oddsapiR_type / quota attributes.

Self-Describing Returns

Echo identity back onto rows when the API omits it: toa_sports_participants() adds a sport_key column; historical wrappers surface the snapshot timestamps. A reader should be able to locate any row without re-deriving the call arguments.

@return tables: 3-column

Every wrapper documents its return with a 3-column markdown table – col_name | types | description:

#'    |col_name      |types     |description                              |
#'    |:-------------|:---------|:----------------------------------------|
#'    |id            |character |Unique event id.                         |

Include a Usage quota cost note in the @description (see the table below).

Messaging Layer

User-facing messages use cli: cli::cli_alert_danger() (errors), cli::cli_alert_warning() (warnings), cli::cli_alert_success() (success). Do not use bare message() in new code.

The Odds API v4 Endpoints & Quota Costs

Function Endpoint Quota cost
toa_sports() /v4/sports Free
toa_sports_odds() /v4/sports/{sport}/odds markets × regions
toa_sports_scores() /v4/sports/{sport}/scores 1 (2 with days_from)
toa_sports_events() /v4/sports/{sport}/events Free
toa_event_odds() /v4/sports/{sport}/events/{eventId}/odds markets × regions
toa_event_markets() /v4/sports/{sport}/events/{eventId}/markets 1
toa_sports_participants() /v4/sports/{sport}/participants 1
toa_sports_odds_history() /v4/historical/sports/{sport}/odds 10 × markets × regions
toa_sports_events_history() /v4/historical/sports/{sport}/events 1 (0 if empty)
toa_event_odds_history() /v4/historical/sports/{sport}/events/{eventId}/odds markets × regions

Historical (/v4/historical/...) endpoints require a paid usage plan. The legacy /v4/sports/{sport}/odds-history path is deprecated – use /v4/historical/sports/{sport}/odds.

Testing

  • One test file per exported function in tests/testthat/.

  • Gate every live test on the key: skip_if_not(has_toa_key(), "ODDS_API_KEY not set"), plus skip_on_cran().

  • Guard against empty/transient responses before asserting: if (!is.data.frame(x) || nrow(x) == 0) skip("No rows returned at test time"). Out-of-season sports legitimately return zero rows.

  • Use subset-direction assertions – expected ⊆ actual, so upstream adding a column never breaks the suite:

    expect_in(sort(cols), sort(colnames(x)))   # NOT expect_equal(colnames(x), cols)
    expect_s3_class(x, "data.frame")
  • Use basketball_nba for current-endpoint tests (in season more of the year than NCAAB); use a fixed historical date (e.g. 2024-01-15T12:00:00Z) for historical tests and skip cleanly when the plan tier returns nothing.

DESCRIPTION

After editing DESCRIPTION, normalize with usethis::use_tidy_description(). httr2 and tibble are in Imports; httr and janitor were removed in 1.0.0. Per SDV convention, keep Rcpp/RcppParallel in Suggests even at zero usage.

Documentation Maintenance

Never hand-edit NAMESPACE or files under man/ – regenerate with devtools::document(). README.md is rendered from README.Rmd via devtools::build_readme(); commit both together. New exported functions must be added to the reference: section of _pkgdown.yml (it lists functions explicitly).

Release notes triad: NEWS.md / cran-comments.md / _pkgdown.yml

When changing the API surface, update all three: NEWS.md (long-form changelog, new bullets under the most recent version heading), cran-comments.md (short release summary submitted to CRAN), and _pkgdown.yml (reference index). Note in the commit message that all three were checked.

Commit Convention

Use Conventional Commits: feat(toa):, fix(toa):, docs:, test(toa):, refactor:, chore:, ci:. Use type!: or a BREAKING CHANGE: footer for breaking changes.

Never include AI agents or assistants (e.g. Claude, Copilot) as commit co-authors. Omit all Co-Authored-By trailers referencing AI tools.

Common Pitfalls

  • Initialize the return variable (data.frame()) before tryCatch – always.
  • Build the query list unconditionally; let httr2::req_url_query() drop NULL values.
  • The current event-odds endpoint has no bookmaker-level last_update; the /odds and historical endpoints do. Don’t blindly copy the bookmaker rename across wrappers.
  • Historical responses are wrapped in a snapshot envelope; current responses are not.
  • Player-prop markets add an outcomes_description column; featured markets don’t. h2h has no outcomes_point. Keep tests subset-direction.
  • Never commit a real ODDS_API_KEY.
  • Don’t hand-edit NAMESPACE/man/; regenerate with devtools::document(). ```