rguides

Building REST APIs with plumber

Building REST APIs in R is straightforward with the plumber package. Plumber transforms your R functions into HTTP endpoints with minimal code; you add special comments called decorators above your functions, and plumber handles the web server, request parsing, and response serialization automatically. This makes it easy to expose R computations to web applications, other services, or external systems.

Installation

Install plumber from CRAN:

install.packages("plumber")
library(plumber)

Plumber works with R 3.6 and later. It bundles a web server based on httpuv, so you do not need to install additional server software. The package handles incoming HTTP connections, routes them to the correct function, and serializes the return value into the appropriate response format.

Your first API

Create a file named plumber.R and annotate your R functions with special comments that plumber reads to determine the HTTP method, URL path, and serialization format. Each annotation starts with #* followed by a plumber tag like @get, @param, or @serializer:

#* Echo back the input message
#* @param msg The message to echo
#* @get /echo
function(msg = "") {
  list(
    msg = paste0("You sent: '", msg, "'")
  )
}

#* Return a histogram plot
#* @get /plot
#* @serializer png
function() {
  hist(rnorm(100), main = "Random Normal Distribution")
}

Start the API by passing the filename to pr(), which parses the annotations and builds a router object, then pipe it into pr_run() to start the HTTP server. The port argument sets the TCP port the server listens on; the default is typically 8000, but you can use any available port. Other arguments like host = "0.0.0.0" make the server accessible from other machines on your network:

pr("plumber.R") |>
  pr_run(port = 8000)

Visit http://localhost:8000/echo?msg=hello to see the echo endpoint return JSON with your message. The ?msg=hello query parameter maps directly to the msg function argument; plumber parses the query string and passes named parameters to your function by name. Visit http://localhost:8000/plot to see the PNG histogram returned as binary image data.

HTTP methods

Plumber supports all common HTTP verbs through corresponding decorator tags. Each annotation line defines one endpoint’s routing behaviour, and you can stack multiple decorators on the same function to combine method, parameter, and serialization annotations:

#* Get a resource
#* @get /users

#* Create a new resource
#* @post /users

#* Update an existing resource
#* @put /users/:id

#* Delete a resource
#* @delete /users/:id

#* Partial update
#* @patch /users/:id

The @get, @post, @put, @delete, and @patch decorators each map to their respective HTTP methods. The @use decorator is a catch-all that handles any method on a route. Note that :id in a path like /users/:id captures the dynamic segment as a function parameter; plumber extracts it from the URL and passes it to your function as a character string.

Path parameters

Capture dynamic segments in URLs using :paramname syntax directly in the decorator path. When a client requests /users/42, plumber extracts the value after /users/ and passes it as the id argument to your function. Parameters are always received as character strings, so convert them with as.integer() or as.numeric() before using them in computations:

#* Get user by ID
#* @get /users/:id
function(id) {
  # id is a character string from the URL
  user <- get_user_by_id(as.integer(id))
  list(user = user)
}

#* Calculate stats between two values
#* @get /range/:min/:max
function(min, max) {
  values <- seq(as.numeric(min), as.numeric(max), length.out = 100)
  list(
    min = min(values),
    max = max(values),
    mean = mean(values),
    sd = sd(values)
  )
}

Request http://localhost:8000/users/42 and plumber passes “42” as the id argument to your function. Path parameters are positional — the order in the URL path determines which function argument receives the value — so name your function arguments to match the parameter names exactly.

Query strings and request bodies

Query parameters become function arguments automatically. When a client appends ?species=setosa&limit=5 to a URL, plumber parses the query string and passes species = "setosa" and limit = "5" to your function. Always set default values for optional query parameters so the API works correctly even when the client omits them from the request:

#* Filter data
#* @get /data
function(species = NULL, limit = 10) {
  data <- iris
  
  if (!is.null(species)) {
    data <- subset(data, Species == species)
  }
  
  head(data, limit)
}

Access request bodies with req$body. When clients send JSON data in a POST or PUT request, typically from a web form, a JavaScript frontend, or another API, plumber parses the body into an R list if you include the @parser json decorator. The req object gives you access to the raw request details, including headers, the parsed body, and the client IP address:

#* Create a new record
#* @post /records
#* @parser json
function(req, res) {
  body <- req$body
  
  # body is now a list parsed from JSON
  new_id <- insert_record(body)
  
  list(
    success = TRUE,
    id = new_id
  )
}

The @parser json decorator tells plumber to parse JSON request bodies into R lists. Without it, plumber defaults to form parsing, which expects URL-encoded form data. If your API accepts JSON from a JavaScript client, include this decorator to avoid errors. Similarly, @parser csv handles CSV-formatted request bodies, and custom parsers can be registered for other formats.

Returning JSON

Plumber automatically serializes R lists to JSON for the HTTP response. This is the default behaviour — any function that returns a list produces a JSON response. For other response formats, use serializers to control the output type and content type header:

#* Return JSON (default)
#* @get /json
#* @serializer json
function() {
  list(status = "ok", data = 1:10)
}

#* Return plain text
#* @get /text
#* @serializer text
function() {
  "Plain text response"
}

#* Return HTML
#* @get /html
#* @serializer html
function() {
  "<html><h1>Hello World</h1></html>"
}

#* Return CSV
#* @get /csv
#* @serializer csv
function() {
  iris[1:5, ]
}

Common serializers include: json, csv, png, jpeg, html, text, and contentType for custom MIME types. The serializer determines both how the response body is encoded and the Content-Type header sent to the client. Choosing the right serializer ensures the client can parse the response correctly — a browser expecting JSON will show an error if your endpoint returns PNG binary data without the proper serializer.

Error handling

Throw errors with stop() or use the response object to set HTTP status codes directly. When an endpoint encounters an invalid request, missing resource, or unexpected condition, returning a meaningful error with the correct status code helps API consumers distinguish between client errors (4xx) and server errors (5xx):

#* Get user or return 404
#* @get /users/:id
function(id, res) {
  user <- tryCatch(
    get_user_by_id(as.integer(id)),
    error = function(e) NULL
  )
  
  if (is.null(user)) {
    res$status <- 404
    return(list(error = "User not found"))
  }
  
  list(user = user)
}

You can also create a filter for global error handling. A filter catches errors from every endpoint that runs after it in the pipeline, letting you centralize logging, format error responses consistently, and avoid leaking internal error details to external clients. The forward() call inside the filter passes control to the next handler in the chain, and any exception thrown downstream is caught by the tryCatch() wrapping the forward() call:

#* Log errors and return clean messages
#* @filter error_log
function(req, res) {
  tryCatch(
    forward(),
    error = function(e) {
      message("Error: ", e$message)
      res$status <- 500
      list(error = "Internal server error")
    }
  )
}

Apply this filter to all endpoints by placing it in your plumber.R file before any endpoint definitions. Filters execute in the order they appear, so put shared filters at the top. Use @preempt filter_name to skip a specific filter for individual endpoints — useful when a subset of routes should bypass authentication or CORS handling.

Filters and middleware

Filters run before endpoints and can modify requests or responses. They are plumber’s middleware mechanism, allowing you to inject cross-cutting concerns like authentication, CORS headers, logging, or rate limiting without repeating code in every endpoint function:

#* Add CORS headers
#* @filter cors
function(req, res) {
  res$setHeader("Access-Control-Allow-Origin", "*")
  res$setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
  forward()
}

#* Require authentication
#* @filter auth
function(req, res) {
  token <- req$headers[["Authorization"]]
  
  if (is.null(token) || !valid_token(token)) {
    res$status <- 401
    return(list(error = "Unauthorized"))
  }
  
  forward()
}

#* This endpoint requires auth
#* @preempt auth
#* @get /protected
function() {
  list(data = "Sensitive information")
}

Programmatic API definition

For complex APIs, build routers programmatically using plumber’s pipe-friendly interface. Instead of defining everything in annotation comments inside a file, you construct endpoints by chaining pr_get(), pr_post(), and other pipe verbs. This approach is powerful when endpoints are generated dynamically, when you want to compose an API from multiple source files, or when you prefer to keep the routing logic alongside your R code rather than in annotation comments:

library(plumber)

pr() |>
  pr_get("/hello", function() list(message = "Hello!")) |>
  pr_post("/sum", function(a, b) as.numeric(a) + as.numeric(b)) |>
  pr_run(port = 8000)

This “tidy” interface makes it easier to conditionally add endpoints or build APIs from multiple files. Programmatic routers are regular R objects, so you can pass them to helper functions that add sets of related endpoints; for example, a function that adds all user-related routes in one call. The resulting router object can still be augmented with annotation-based endpoints from a file using pr(), letting you mix both styles.

Deployment

Run plumber in production with a process manager or container. The host = "0.0.0.0" argument binds to all network interfaces, making the API accessible from other machines. Port selection via Sys.getenv("PORT") follows the convention used by cloud platforms like Heroku and Render, which inject the port number through an environment variable:

# Run as a background process on a specific port
plumb("plumber.R") |>
  pr_run(host = "0.0.0.0", port = Sys.getenv("PORT", 8000))

Common deployment options:

  1. Posit Connect: Push-button deployment from RStudio
  2. Docker: Package R + plumber in a container
  3. Systemd: Run as a Linux service
  4. Cloud platforms: Render, Heroku, AWS ECS

For Docker, create a app.R file:

library(plumber)

plumb("plumber.R") |>
  pr_run(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", 8000)))

Then create a Dockerfile in the same directory to containerize the application. The Dockerfile installs R and the plumber package, copies your API files into the image, and sets the default command to run your app.R script. Once built, the container starts the plumber server on the exposed port:

FROM r-base:latest
RUN R -e "install.packages('plumber')"
COPY app.R plumber.R .
EXPOSE 8000
CMD ["R", "-e", "source('app.R')"]

Input validation

Validate API inputs at the route level before processing. Use type annotations in @param comments to document expected types. Inside route functions, check inputs explicitly: if (!is.numeric(x) || x < 0) stop("x must be a non-negative number"). plumber converts R errors to HTTP 500 responses; use stop() for server errors and req$app$respond(plumber_error(400, "Bad request")) for client errors.

Authentication

Add authentication as a filter that runs before route handlers. A bearer token filter: #* @filter auth followed by a function that reads req$HTTP_AUTHORIZATION, validates the token, and calls plumber::forward() on success or returns a 401 response on failure. Filters apply to all routes by default; use @preemptive TRUE on specific routes to skip the filter.

OpenAPI documentation

plumber auto-generates an OpenAPI (Swagger) specification from route annotations. The spec is served at /__docs__/ when the API runs. Add #* @apiTitle My API and #* @apiDescription Description text at the top of the plumber file for metadata. Parameter descriptions come from @param comments. pr_set_docs() customizes the documentation page. The generated spec can be imported into Postman, Insomnia, or any OpenAPI-compatible tool for testing and client generation.

Plumber basics

plumber converts annotated R functions into REST API endpoints. Special comments above functions define the route and HTTP method. #* @get /path creates a GET endpoint; #* @post /path creates POST.

The function return value becomes the response body, serialized as JSON by default. Return a list for JSON output, a data frame for JSON arrays, or use a custom serializer for other formats.

Launch the API: pr("api.R") %>% pr_run(port = 8000). This blocks the R session. For non-blocking use in development: pr("api.R") %>% pr_run(port = 8000, quiet = TRUE) in a background process, or use RStudio’s “Run API” button which handles this.

Route parameters and types

Path parameters use angle brackets: #* @get /items/<id> captures id as a function argument. The value is always a character string. Coerce explicitly: id_int <- as.integer(id). Check for NA after coercion to catch non-numeric inputs.

Query parameters are function arguments with defaults. function(page = 1, per_page = 20) makes page and per_page optional query parameters with defaults. Access via ?page=2&per_page=50 in the URL.

Type annotations in the comments: #* @param id:int The item ID documents the expected type. Add #* @param page:int Page number for query parameters. These annotations appear in the auto-generated OpenAPI documentation.

Request and response objects

req and res as function arguments give access to the HTTP request and response. req$postBody returns the raw POST body string. req$HTTP_AUTHORIZATION returns the Authorization header. req$argsBody returns parsed body arguments.

res$status <- 201L sets the HTTP status code (default 200). res$setHeader("X-Total-Count", total) sets a response header. res$body <- raw_bytes; res$headers$content-type <- "image/png" sends binary data.

OpenAPI and documentation

plumber generates OpenAPI (formerly Swagger) documentation automatically from the annotations. #* @title Item API sets the title. #* @description Returns item details documents a specific endpoint. #* @param id Item identifier documents a parameter. Access the documentation UI at /docs/ when the API is running.

#* @tag items groups endpoints under a tag in the documentation, making it easier to navigate large APIs.

Performance and scaling

A single plumber process handles one request at a time. For concurrent requests, run multiple processes behind a load balancer. heroku with multiple dynos, Kubernetes with multiple pods, or pm2 -i 4 for four processes on a single machine all achieve parallelism.

For CPU-intensive endpoints, future::plan("multicore") inside the endpoint function runs the computation in a worker process. However, this is complex and future contexts do not inherit the plumber request/response, test carefully.

Response caching at the API or reverse proxy level helps for idempotent GET requests that return the same data for the same parameters. memoise::memoise(expensive_function) caches at the R function level.

API design considerations

A well-designed plumber API separates concerns cleanly: route handlers validate input, call business logic functions, and format the response. Business logic should live in separate R files or packages that are imported, not embedded in the route handler. This separation keeps route handlers small and testable, and makes the business logic reusable outside the API context.

Response format consistency matters for API consumers. All endpoints should return the same top-level structure, a data field for success, an error field for failure — so consumers can handle responses uniformly. Using serializer_json() consistently and setting appropriate HTTP status codes with res$status <- 400 for client errors and res$status <- 500 for server errors follows standard REST conventions.

Testing plumber aPIs

Testing a Plumber API before deployment catches integration problems early. The httr2 package can drive HTTP requests against a locally running plumber instance. Start the API in a background process with callr::r_bg(), run requests against localhost, and check response status codes and body contents. testthat tests wrap these HTTP calls, giving you automated regression coverage for your API endpoints. For endpoints that call databases or external services, use httptest2 to record and replay HTTP interactions, making tests fast and deterministic.

See also

Plumber is the standard way to expose R computations as web services. Start simple with one endpoint, then add routing, authentication, and filters as your API grows.