To call an Amazon product API from R, send a GET request with httr::GET to /api/amazon-product-lookup with an ASIN, a marketplace ID and your API key in the X-Api-Key header, then decode the body with jsonlite::fromJSON. This quickstart builds a small client for three endpoints (lookup, offers and statistics), shows where jsonlite's own default parsing produces a value nrow() cannot read as zero, and adds a retry policy that only resends requests a second attempt can actually fix.
Key Takeaways
- jsonlite::fromJSON's default simplifyVector=TRUE turns a JSON array into a data.frame, but turns an EMPTY array into a plain list() instead of a 0-row data.frame - nrow() on it is NULL, not 0, and a naive seq_len(nrow(...)) loop crashes on exactly the "no offers" case.
- Set httr::timeout(120): the API can take that long to answer, and an abandoned request is still billed.
- Send the key with httr::add_headers(
X-Api-Key= key), never in the URL as a query parameter. - Base R's default regex engine rejects a trailing newline before "$" correctly; switching to perl=TRUE (PCRE) reintroduces the same trap documented for Perl/Elixir/PowerShell in this series.
- Retry 429, a Retry-After-less 500, 502 and 503 only. A 404 is a billed answer; retrying it pays for the same answer again.
What do I need before I start?
- R 4.x with the
httrandjsonlitepackages (install.packages(c("httr", "jsonlite")), orr-cran-httr/r-cran-jsonlitevia apt on Debian/Ubuntu); neither ships with base R. - An API key - a free account includes 150 credits, one per request.
- The key in an environment variable, never hard-coded in a script you might commit.
Install the packages and set the key
sudo apt-get install -y r-cran-httr r-cran-jsonlite
export SELLERMAGNET_API_KEY="your-key-here"
Rscript main.R
How do I call the Amazon product API from R?
Keep the base URL, the key and the timeout together, and build one request helper that every endpoint shares. The key goes into httr::add_headers(), never the URL: a key left in a URL ends up in shell history and request logs, and the header vs query string guide shows where that leaks. is_asin() filters a malformed ASIN before it can cost a credit on lookup or offers, or trigger a billed 400 on statistics.
SellerMagnet.R - config, the ASIN check and one request helper
# SellerMagnet.R - a small httr + jsonlite client for the SellerMagnet API.
library(httr)
library(jsonlite)
# ASIN check: base R's default TRE regex engine correctly rejects a trailing
# newline before "$", but perl=TRUE (PCRE) accepts one, the same trap documented
# for Perl/Elixir/PowerShell in this series - verified directly, not assumed.
# Stay on the default (non-PCRE) engine for this exact reason.
is_asin <- function(asin) {
is.character(asin) && length(asin) == 1 && grepl("^[A-Z0-9]{10}$", asin)
}
new_session <- function(api_key = Sys.getenv("SELLERMAGNET_API_KEY")) {
if (nchar(api_key) == 0) stop("SELLERMAGNET_API_KEY is not set")
list(api_key = api_key)
}
# One raw GET, no retry logic here. The key goes in the X-Api-Key header,
# never the query string - a key in a URL ends up in shell history and logs.
sm_request <- function(session, endpoint, params) {
base <- Sys.getenv("SELLERMAGNET_API_BASE", "https://sellermagnet-api.com/api")
httr::GET(paste0(base, "/", endpoint), query = params,
httr::add_headers(`X-Api-Key` = session$api_key),
httr::timeout(120))
}
# jsonlite trap, verified live: an EMPTY JSON array simplifies to a plain
# list(), not a 0-row data.frame, so nrow() on it is NULL, not 0. Every reader
# of a list-typed field (offers, variations, ...) must check is.data.frame()
# before calling nrow() or indexing rows, or a naive loop throws
# "argument must be coercible to non-negative integer" on the no-offers case.
has_rows <- function(x) is.data.frame(x) && nrow(x) > 0
Why does an empty offer list break nrow()?
Because jsonlite::fromJSON's default simplifyVector = TRUE turns a JSON array of objects into a data.frame so each field can be read as a vector - but only when the array has at least one element. An EMPTY array ("offers": [], the response when a listing has no Buy Box and no third-party offers) simplifies to a plain list() instead of a 0-row data.frame. nrow() on a plain list is NULL, not 0 - verified directly on this host, not assumed from another language's post in this series. A loop written the obvious way, for (i in seq_len(nrow(x$offers))), throws Error: argument must be coercible to non-negative integer on exactly the case that matters most for a price client: a listing with no offers. has_rows() below checks is.data.frame(x) && nrow(x) > 0 before any row access, which is safe whether the array had zero, one or many elements.
Reproduced directly with Rscript
> fromJSON('{"offers":[]}')$offers
list()
> class(fromJSON('{"offers":[]}')$offers)
[1] "list"
> nrow(fromJSON('{"offers":[]}')$offers)
NULL
> for (i in seq_len(nrow(fromJSON('{"offers":[]}')$offers))) i
Error in seq_len(nrow(...)) : argument must be coercible to non-negative integer

Why is the statistics price a different number?
Because /amazon-product-statistics reports buyBoxPrice in integer minor units: 41800 means 418.00. Lookup and offers report decimal units, and the lookup price excludes shipping while the statistics and offers prices are landed (item plus shipping), so 409.99 from lookup and 41800 from statistics can describe the same offer. jsonlite decodes the integer as an R numeric, not a string, so divide by 100 for the major unit - except on amazon.co.jp, where the values are whole yen. More detail is in the cents vs decimal guide. Statistics also answers a free 400 (not billed, not retried) outside the 11 marketplaces with full price/BSR history.
What should an R client retry?
Retry only what a second attempt can fix. A 429 from the per-key concurrency cap is free and carries Retry-After; httr::headers(resp)[["retry-after"]] gives that wait. A 500 without Retry-After is refunded, so one retry costs nothing beyond the retry itself; a 500 that carries Retry-After stays charged. 502 and 503 are billed on every attempt, so wait at least the Retry-After and cap the attempts. httr::GET never retries anything on its own - a failed call just returns a response object with a non-2xx status, and this loop decides what gets sent twice and nothing else does.
| Status | Meaning | Charged | Client action |
|---|---|---|---|
200 | Success | Yes | Parse data |
400 gate | Missing parameter or uncovered marketplace on statistics | No | Fix the call |
400 endpoint | Malformed ASIN on statistics | Yes | Fix the call |
401 / 403 | Key or credits | No | Stop the run |
404 | Not on this marketplace | Yes | Record, never retry |
429 | Concurrency cap | No | Wait Retry-After |
500 | Internal error, no Retry-After | No, refunded | Retry once |
502 / 503 | Upstream error / breaker | Yes | Wait, retry, cap |
Retry.R - get_with_retry, billing-aware retries on top of SellerMagnet.R
# Retry.R - billing-aware retry on top of SellerMagnet.R::sm_request.
# Policy (FACTS.md, status codes and billing):
# free, retry: 429 (honours Retry-After, up to MAX_ATTEMPTS)
# free, no retry: 401, 403, a gate 400 (missing/invalid marketplaceId)
# refunded, retry once: 500 without Retry-After
# billed, retry cap: 502, 503 (up to MAX_ATTEMPTS, each one billed)
# billed, no retry: 404, an endpoint 400 (e.g. a malformed ASIN on statistics)
source("SellerMagnet.R")
MAX_ATTEMPTS <- 4
# Returns list(status, body, attempts). body is jsonlite::fromJSON() of the
# response text, or NULL for a malformed-ASIN call that was never sent.
get_with_retry <- function(session, endpoint, params, asin_param = "asin") {
if (!is.null(params[[asin_param]]) && !is_asin(params[[asin_param]])) {
return(list(status = NA, body = NULL, attempts = 0, skipped = TRUE))
}
attempt <- 1
while (TRUE) {
resp <- sm_request(session, endpoint, params)
status <- httr::status_code(resp)
if (status %in% c(401, 403)) {
return(list(status = status, body = NULL, attempts = attempt, stop = TRUE))
}
if (status == 429 && attempt < MAX_ATTEMPTS) {
ra <- httr::headers(resp)[["retry-after"]]
Sys.sleep(if (is.null(ra)) 1 else as.numeric(ra))
attempt <- attempt + 1
next
}
if (status == 500 && attempt < 2) {
attempt <- attempt + 1
next
}
if (status %in% c(502, 503) && attempt < MAX_ATTEMPTS) {
ra <- httr::headers(resp)[["retry-after"]]
Sys.sleep(if (is.null(ra)) 1 else as.numeric(ra))
attempt <- attempt + 1
next
}
body <- tryCatch(jsonlite::fromJSON(httr::content(resp, as = "text", encoding = "UTF-8")),
error = function(e) NULL)
return(list(status = status, body = body, attempts = attempt))
}
}
lookup <- function(session, asin, marketplace_id) {
get_with_retry(session, "amazon-product-lookup", list(asin = asin, marketplaceId = marketplace_id))
}
offers <- function(session, asin, marketplace_id) {
get_with_retry(session, "amazon-product-offers", list(asin = asin, marketplaceId = marketplace_id))
}
statistics <- function(session, asin, marketplace_id) {
get_with_retry(session, "amazon-product-statistics", list(asin = asin, marketplaceId = marketplace_id))
}

Run lookup and statistics for five products
main.R checks each ASIN's format first with is_asin(), because a malformed ASIN still costs a credit on lookup and a billed 400 on statistics. It stops a row after a failed lookup instead of calling statistics for an ASIN that was never confirmed to exist on that marketplace, and prints one row per product.
main.R - lookup then statistics, one row per product
# main.R - lookup then statistics for five products, one row per product.
# A malformed ASIN costs nothing: Test-SMAsin-equivalent is_asin() in
# SellerMagnet.R rejects it before any request leaves this script.
suppressWarnings(suppressMessages(source("Retry.R")))
session <- new_session()
MARKETPLACE <- "ATVPDKIKX0DER" # US
PRODUCTS <- c("B0CL61F39H", "B0CLTBHXWQ", "B0DEMO0003", "B0DEMO0004", "B0DEMO-BAD")
for (asin in PRODUCTS) {
if (!is_asin(asin)) {
cat(sprintf("%-12s SKIPPED malformed ASIN, 0 credits\n", asin))
next
}
l <- lookup(session, asin, MARKETPLACE)
if (is.null(l$body) || l$status != 200) {
cat(sprintf("%-12s lookup status %s, stopping row (1 credit)\n", asin, l$status))
next
}
price <- l$body$data$productInfo$buyBoxInfo$price
price_txt <- if (identical(price, "N/A")) "N/A" else sprintf("%.2f", price)
s <- statistics(session, asin, MARKETPLACE)
stats_txt <- if (s$status == 200) sprintf("%d cents", s$body$data$buyBoxPrice) else sprintf("status %s", s$status)
cat(sprintf("%-12s lookup price %-8s statistics %s\n", asin, price_txt, stats_txt))
}
Program output (illustrative data, from the local fake server this code was tested against)
B0CL61F39H lookup price 444.99 statistics 44499 cents
B0CLTBHXWQ lookup status 404, stopping row (1 credit)
B0DEMO0003 lookup price N/A statistics 41800 cents
B0DEMO0004 lookup price 19.99 statistics status 400
B0DEMO-BAD SKIPPED malformed ASIN, 0 credits
The credit math for that run: 3 billed lookups returned data (rows 1, 3, 4) plus 1 billed 404 (row 2) plus 2 statistics calls that were billed (rows 1 and 3; row 4's statistics call hit the free gate 400 for a marketplace outside the 11 with full history, so it cost nothing) = 6 credits. The fifth row's malformed ASIN never left the script, so it cost nothing either. Row 3 shows the point of the table above: lookup reports the literal string "N/A" (no featured offer at that instant) while statistics still has a recorded price of 41800 cents (418.00) from its own history. To test changes without spending credits, point SELLERMAGNET_API_BASE at a local fake HTTP server; that is how this code was tested, with no network call ever reaching the real API.
How was this tested without a formal R test framework?
Against a real local HTTP/1.1 server built on httpuv::startServer (scn_server.R), the same "real local server, no hand-written mock of the client" convention as the C, Swift, Rust, Perl, Elixir and PowerShell quickstarts in this series - never a hand-written stand-in for SellerMagnet.R or Retry.R themselves. Each scenario spawns the server as its own background OS process rather than sharing the test driver's R interpreter: a synchronous httr::GET call and httpuv's own event loop cannot share one process without deadlocking, since httpuv's request callback only runs through R's event-loop machinery, which never gets a turn while httr::GET is blocked inside libcurl - confirmed directly on this host, not assumed. scenarios.R runs 28 plain assertions against the unmodified modules: the happy path, a billed 404 that is never retried, a free gate 400, a 500 that recovers on retry, two consecutive 500s that stop at the second, a 502-then-503-then-200 sequence with every attempt billed, a 503 capped at 4 attempts, 401 and 403 stopping at once, a 429 that waits for its Retry-After before retrying, the empty-array nrow() trap in both directions, the "N/A" price string, integer-cents statistics, a transport-level connection failure, a missing API key failing closed, and the ASIN trailing-newline trap under both regex engines. All 28 pass.
Rscript scenarios.R (28/28 assertions pass)
28 passed, 0 failed
Frequently Asked Questions
Is there an official R package for the SellerMagnet API?
No, and none is needed. Every endpoint is a plain GET with query parameters and a JSON body that jsonlite already parses, so a client fits in well under 100 lines of R.
Why does nrow() sometimes return NULL instead of 0?
Because jsonlite's default simplifyVector=TRUE decodes an empty JSON array as a plain list(), not a 0-row data.frame. Check is.data.frame(x) before calling nrow(x) or indexing rows.
Does httr::GET retry anything on its own?
No. A failed call returns a response object with a non-2xx status and stops there; nothing in httr resends it automatically. The retry loop in this post is the only thing deciding what gets sent twice.
Why compare the lookup price to the string "N/A" instead of just using it as a number?
Because productInfo.buyBoxInfo.price really is the string "N/A" when no offer is featured. R's loose typing will not raise an error if you skip the check, it will just produce a wrong number from the comparison or arithmetic later.
Is grepl("^...$", asin) safe to use as an ASIN check?
With base R's default (non-PCRE) engine, yes - it correctly rejects a trailing newline. Add perl=TRUE and it stops being safe, matching the same trap documented for Perl, Elixir and PowerShell in this series.
Bottom line: one httr::GET call per request with a 120-second timeout and the key in an X-Api-Key header, is.data.frame() checked before every nrow() on a list-typed field, explicit "N/A" checks before any price arithmetic, and retries for 429, 500, 502 and 503 only. The same client shape exists for Python and Go, and every endpoint is in the API documentation.