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:
mainis the default branch and release branch. -
HTTP stack:
httr2(migrated fromhttrin 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 therequests_remaining/requests_usedquota data.frame (used bytoa_requests()). -
NULLquery values are dropped automatically byhttr2::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:
- Attached as attributes (
oddsapiR_requests_remaining/_used/_last) to every returned tibble bymake_toa_data()(the dplyr/tidyr transforms strip attributes from the intermediate parse, so they are attached at the end, not on the raw response). - Echoed when an
oddsapiR_datatibble is printed (print.oddsapiR_data). - 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/_pointFor 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"), plusskip_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:
Use
basketball_nbafor current-endpoint tests (in season more of the year than NCAAB); use a fixed historicaldate(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()) beforetryCatch– always. - Build the query list unconditionally; let
httr2::req_url_query()dropNULLvalues. - The current event-odds endpoint has no bookmaker-level
last_update; the/oddsand 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_descriptioncolumn; featured markets don’t.h2hhas nooutcomes_point. Keep tests subset-direction. - Never commit a real
ODDS_API_KEY. - Don’t hand-edit
NAMESPACE/man/; regenerate withdevtools::document(). ```
