Building REST APIs with plumber

· 5 min read · Updated March 11, 2026 · intermediate
r plumber api rest web

Plumber transforms your R functions into REST API endpoints with minimal code. You add special comments (decorators) above your functions, and plumber handles the HTTP server, request parsing, and response serialization. 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.

Your First API

Create a file named plumber.R:

#* 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 with:

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

Visit http://localhost:8000/echo?msg=hello to see the echo endpoint. The ?msg=hello query parameter maps to the msg function argument. Visit http://localhost:8000/plot to see the PNG histogram.

HTTP Methods

Plumber supports all common HTTP verbs:

#* 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 @use decorator handles all methods on a route. Each method maps to the corresponding HTTP request type.

Path Parameters

Capture dynamic segments in URLs using :paramname:

#* 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.

Query Strings and Request Bodies

Query parameters become function arguments automatically:

#* 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:

#* 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. Without it, plumber defaults to form parsing.

Returning JSON

Plumber automatically serializes lists to JSON. For other formats, use serializers:

#* 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.

Error Handling

Throw errors with stop() or use the response object:

#* 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:

#* 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. Use @preempt filter_name to skip a filter for specific endpoints.

Filters and Middleware

Filters run before endpoints and can modify requests or responses:

#* 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:

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.

Deployment

Run plumber in production with a process manager or container:

# 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 build and run:

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

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.