R scripts and Shiny apps lean on APIs constantly โ and the usual development answers all hurt a little:
.rds/.json fixture โ instant, but your actual HTTP code, pagination loop and error handling never run;localhost: a Shiny app deployed to shinyapps.io or Posit Connect can't reach your laptop, and neither can a teammate's script;The boring fix is a hosted mock API: a real https URL that answers your console, your CI, your colleague's laptop and your deployed Shiny app alike. Every R snippet below was run verbatim with R 4.3 + httr2 1.0 against the live endpoints on this page before publishing โ the printed outputs are from those runs.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
Or skip the terminal entirely:
jsonlite::fromJSON("https://mockbird.mockbird.workers.dev/m/demo/products?limit=3")
The modern R HTTP client is httr2: build a request with pipes, perform it, parse the response. Sorting, filtering and pagination are query params on the mock, so req_url_query() does the heavy lifting:
library(httr2)
resp <- request("https://mockbird.mockbird.workers.dev/m/demo") |>
req_url_path_append("products") |>
req_url_query(limit = 5, sortBy = "price", order = "desc") |>
req_perform()
products <- resp_body_json(resp)
resp_status(resp) # 200
length(products) # 5, most expensive first
resp_header(resp, "x-total-count") # "30" โ total across all pages
Every list endpoint sends X-Total-Count, so you always know how much is left without a second call.
For flat records, jsonlite::fromJSON() simplifies to a typed data.frame in one line โ note price comes back num and inStock comes back logi, not strings:
library(jsonlite)
df <- fromJSON("https://mockbird.mockbird.workers.dev/m/demo/products?limit=100")
str(df, max.level = 1)
# 'data.frame': 30 obs. of 8 variables:
# $ id : int 1 2 3 4 5 ...
# $ name : chr "World End" ...
# $ price : num 874 114 133 346 527 ...
# $ inStock : logi TRUE FALSE FALSE ...
When you'd rather exercise a real pagination loop (the thing that breaks in production), httr2 + X-Total-Count:
fetch_all <- function(url, page_size = 10) {
page <- 1; out <- list()
repeat {
resp <- request(url) |>
req_url_query(page = page, limit = page_size) |>
req_perform()
rows <- resp_body_json(resp, simplifyVector = TRUE)
if (length(rows) == 0 || nrow(rows) == 0) break
out[[page]] <- rows
total <- as.integer(resp_header(resp, "x-total-count"))
if (page * page_size >= total) break
page <- page + 1
}
do.call(rbind, out)
}
df <- fetch_all("https://mockbird.mockbird.workers.dev/m/demo/products")
# 3 pages fetched -> 30 rows
This is the part R people don't expect. You already have data.frames; Mockbird imports CSV. So one function turns any data.frame into a live, typed REST API โ here's mtcars:
library(httr2)
df_to_mock_api <- function(df, resource = "rows") {
csv <- paste(capture.output(write.csv(df, row.names = FALSE)), collapse = "\n")
request("https://mockbird.mockbird.workers.dev/api/projects/import") |>
req_url_query(resource = resource) |>
req_body_raw(csv, type = "text/csv") |>
req_perform() |>
resp_body_json()
}
api <- df_to_mock_api(transform(mtcars, car = rownames(mtcars)), "cars")
api$id # your project id, e.g. "jhp288smkx"
api$adminKey # save this โ it's the project's admin credential
Columns are typed on import (numbers stay numbers), which means range filters work immediately โ no server code, no plumber, nothing to deploy:
curl "https://mockbird.mockbird.workers.dev/m/YOUR_ID/cars?mpg_gte=30&select=car,mpg"
# [ {"id":18,"car":"Fiat 128","mpg":32.4},
# {"id":19,"car":"Honda Civic","mpg":30.4},
# {"id":20,"car":"Toyota Corolla","mpg":33.9},
# {"id":28,"car":"Lotus Europa","mpg":30.4} ]
Share that URL with a colleague, point a prototype frontend at it, or use it as the fixture your Shiny app develops against. (Anonymous projects are capped at 10/day per IP โ limits โ and you can delete one with a single authenticated DELETE.)
Unlike read-only fake APIs, POSTs persist โ so you can test the full round-trip:
base <- "https://mockbird.mockbird.workers.dev/m/YOUR_ID"
created <- request(base) |>
req_url_path_append("cars") |>
req_body_json(list(car = "Citroen 2CV", mpg = 47, cyl = 2, hp = 29)) |>
req_perform() |>
resp_body_json()
created$id # 33
back <- request(base) |>
req_url_path_append("cars", created$id) |>
req_perform() |>
resp_body_json()
back$car # "Citroen 2CV" โ it's really there
This is where a mock with simulation flags earns its keep: real HTTP failures, deterministic, from query params. First, prove your retry logic recovers โ mock_seq=503,503,200 serves exactly that sequence (keyed, so parallel runs don't interfere):
key <- paste0("run-", format(Sys.time(), "%H%M%S"))
resp <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
req_url_query(limit = 1, mock_seq = "503,503,200", mock_seq_key = key) |>
req_retry(max_tries = 4) |>
req_perform()
# Waiting 3s for retry backoff โ โ โ โ โ โ โ โ โ โ โ โ โ โ
# Waiting 3s for retry backoff โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ โ
resp_status(resp) # 200 โ after two real 503s and real backoff
The gotcha this drill catches: httr2's req_retry() only treats 429 and 503 as transient by default. Point the same code at mock_seq=500,500,200 and it fails immediately โ no retry:
# default req_retry() does NOT retry a 500:
# Error: HTTP 500 Internal Server Error.
# tell httr2 which statuses are transient for YOUR upstream API:
resp <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
req_url_query(limit = 1, mock_seq = "500,500,200", mock_seq_key = key) |>
req_retry(max_tries = 4,
is_transient = function(resp) resp_status(resp) %in% c(429, 500, 502, 503)) |>
req_perform()
resp_status(resp) # 200 โ now it recovers
Rate limits, with real countdown headers and a real Retry-After โ mock_ratelimit=3 allows 3 requests per rolling minute per key:
key <- paste0("rl-", format(Sys.time(), "%H%M%S"))
req <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
req_url_query(limit = 1, mock_ratelimit = 3, mock_ratelimit_key = key)
# call 1 -> 200 remaining: 2 retry-after: -
# call 2 -> 200 remaining: 1 retry-after: -
# call 3 -> 200 remaining: 0 retry-after: -
# call 4 -> 429 remaining: 0 retry-after: 11
httr2 treats 429 as transient and sleeps for Retry-After automatically โ add req_retry() and the fourth call quietly waits out the window instead of crashing your script. Finally, slowness โ does your code give up gracefully?
slow <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
req_url_query(limit = 1, mock_delay = 3000) |> # server answers after 3s
req_timeout(2) # you give up after 2s
tryCatch(req_perform(slow), error = function(e) conditionMessage(e))
# "Failed to perform HTTP request."
# Caused by: Timeout was reached ... after 2002 milliseconds with 0 bytes received
The demo (and any project) has JWT login endpoints โ any email/password pair works, and the token is a real signed JWT:
login <- request("https://mockbird.mockbird.workers.dev/m/demo/auth/login") |>
req_body_json(list(email = "ana@example.com", password = "anything")) |>
req_perform() |>
resp_body_json()
me <- request("https://mockbird.mockbird.workers.dev/m/demo/auth/me") |>
req_auth_bearer_token(login$token) |>
req_perform() |>
resp_body_json()
me$user$email # "ana@example.com"
Flip a project to protected mode and every endpoint requires the Bearer token โ so your script's 401-handling path gets exercised too, including token expiry (mint one with expiresIn = 5 and watch your refresh logic actually run).
Because the mock is a public https URL with open CORS, it works identically from runApp() on your laptop and from a deployed shinyapps.io app โ no "works locally, 404s in production" surprises:
library(shiny)
library(httr2)
ui <- fluidPage(
titlePanel("Products (mock API)"),
selectInput("cat", "Category",
c("all", "electronics", "books", "toys", "home", "sports")),
tableOutput("table")
)
server <- function(input, output, session) {
products <- reactive({
req <- request("https://mockbird.mockbird.workers.dev/m/demo/products") |>
req_url_query(limit = 100)
if (input$cat != "all") req <- req |> req_url_query(category = input$cat)
req_perform(req) |> resp_body_json(simplifyVector = TRUE)
})
output$table <- renderTable(products()[, c("name", "price", "category")])
}
shinyApp(ui, server)
(Verified headlessly with shiny::testServer(): the reactive returns all 30 rows on "all" and narrows to a single category when the input changes.) Swapping to the real API later is a one-line base-URL change โ which is the whole point of developing against a mock that behaves like a real server.
| httptest2 / vcr + webmockr | webfakes | plumber | hosted mock (Mockbird) | |
|---|---|---|---|---|
| Offline / CRAN-check safe | โ โ designed for it | โ (local process) | โ if local | โ โ needs network |
| Real HTTP through curl | โ (intercepted in-process) | โ | โ | โ |
| Reachable from shinyapps.io / a teammate / CI | โ | โ (localhost) | only if you host it | โ โ it's a URL |
| Setup | test-file scaffolding | write app in R | write + run + host API | one curl (or one function, ยง4) |
| Inject 503s / 429s / delays deterministically | stub them by hand | write handlers | write handlers | one query param |
| Persistent CRUD + filters + pagination for free | โ | โ | you build it | โ |
Be clear-eyed: for unit tests of an API-wrapper package โ especially one headed to CRAN, where checks must pass offline โ httptest2 or vcr record/replay real responses and are the right tool; r-lib's webfakes gives you a fully local fake server with zero network flake. Use the hosted mock for the things those can't do: integration tests through real curl, Shiny apps that must fetch from wherever they're deployed, failure drills against real status codes, and sharing one dataset across a team.
curl -s -X POST https://mockbird.mockbird.workers.dev/api/projects \
-H "content-type: application/json" -d '{"preset":"ecommerce"}'?mock_snapshot=name), so testthat integration tests don't flake when data changes.GET /m/YOUR_ID/db.json exports everything in json-server format โ no lock-in.Related: CSV โ REST API, mock APIs for Python, mock APIs for PyQt/PySide, mock APIs for Streamlit, mock APIs for Gradio, testing loading and error states, mock JWT auth, deterministic test data with snapshots, and free mock-API tools compared.
Verification: every R snippet on this page was run verbatim with R 4.3.3 + httr2 1.0.0 + jsonlite + shiny 1.8 (Linux) against production on 22 Sep 2026 โ the printed outputs shown are from those runs. The mtcars section ran with the exact function shown against a real project (since deleted); the Shiny server logic ran headlessly via shiny::testServer(). Demo data reseeds daily, so names, prices and per-category counts will differ when you run it. If a snippet here doesn't work in your R session, that's a bug: tell us.