Skip to contents

Canvas has a well documented application programming interface (API) to allow automated creation, modification and deletion of quizzes, modules, users, user groups and so on.

This is what library(examiner) uses behind the scenes to generate quizzes from R markdown files, with the help of the excellent library(httr2). Both have detailed documentation:

You can easily use the examiner function canvas_api() as a basis for any API operation you might desire.

Example

Here a simple example, creating an empty quiz with a title and description:

quiz_data <- list(quiz = list(title = "Quiz created by API",
                              description = "Quiz description"))
examiner::canvas_api("quizzes") |> 
  httr2::req_body_json(quiz_data) |> 
  httr2::req_perform() |> 
  httr2::resp_body_json() -> quiz

The structure of quiz_data matches the specification in the Canvas API documentation to Create a quiz. Note that the nested list quiz_data corresponds to the notation quiz[title], quiz[description] in the Canvas API docs.

The structure of the returned quiz object is explained at the top of the Quizzes API docs.

quiz[1:3]
## $id
## [1] 17476
## 
## $title
## [1] "Quiz created by API"
## 
## $html_url
## [1] "https://nmbu.test.instructure.com/courses/6074/quizzes/17476"

We recognize the description we gave the quiz, amidst a couple of scripts from Instructure, the makers of Canvas:

quiz$description
## [1] "<link rel=\"stylesheet\" href=\"https://instructure-uploads-eu.s3.eu-west-1.amazonaws.com/account_104530000000000001/attachments/1023594/custom.css\">Quiz description<script src=\"https://instructure-uploads-eu.s3-eu-west-1.amazonaws.com/account_104530000000000001/attachments/31997/hp5.js\"></script><script src=\"https://instructure-uploads-eu.s3.eu-west-1.amazonaws.com/account_104530000000000001/attachments/1132271/custom.js\"></script>"

To clean up after the example, we can delete the quiz:

examiner::canvas_api("quizzes", quiz$id) |> 
  httr2::req_method("DELETE") |> 
  httr2::req_perform()
## <httr2_response>
## DELETE https://nmbu.test.instructure.com/api/v1/courses/6074/quizzes/17476
## Status: 200 OK
## Content-Type: application/json
## Body: In memory (2660 bytes)

You see a similar pattern as when we created the quiz: Begin making a request, modify it with req_… functions, then req_perform().

Now, let’s look in more detail at the quiz creation code. canvas_api() creates a httr2 object, which we modify with other httr2 functions, until req_perform() actually sends the request to the Canvas server.

examiner::canvas_api("quizzes")
## <httr2_request>
## GET https://nmbu.test.instructure.com/api/v1/courses/6074/quizzes
## Headers:
## • Authorization: "<REDACTED>"
## Body: empty

Printing a httr2 object tells you what kind of request it is, to what server, and what “API endpoint” (similar to a “function” in R) will be called. There are roughly four types of request: GET to read existing information, POST to create new items, PUT to update and DELETE to delete them.

In canvas_api(), the URL is built by examiner from the CANVAS_DOMAIN and CANVAS_COURSE_ID environment variables, and the authorization is made using the CANVAS_API_TOKEN environment variable, all of which are set in your examiner RStudio project’s .Renviron file. That file is created when you fill in the new project wizard with RStudio > New Project > New Directory > Quizzes using examiner. There are other ways to set environment variables, but we won’t go into that here.

Next, the req_body_json() function adds variables to the request body. This automatically converts the query into POST. (By definition, GET queries have no body, though they can have query parameters in the request URL.)

examiner::canvas_api("quizzes") |> 
  httr2::req_body_json(quiz_data)
## <httr2_request>
## POST https://nmbu.test.instructure.com/api/v1/courses/6074/quizzes
## Headers:
## • Authorization: "<REDACTED>"
## Body: json encoded data

API calls outside the “courses” context

Most of the API calls that examiner use a path beginning with <server>/api/v1/courses/<course_id>/, and therefore examiner::canvas_api() always includes /courses/<course_id> in the URLs it generates.

However, some API calls are outside the courses scope. For example, the URL to get a single group is:

GET /api/v1/groups/:group_id

To work around this, you can use ../.. to go “up a directory” twice (once for the course id and once for “courses”).

# Construct a HTTP request object
req <- examiner::canvas_api("../../groups") |> 
  httr2::req_body_form(name = "Test group")
req
## <httr2_request>
## POST https://nmbu.test.instructure.com/api/v1/courses/6074/../../groups
## Headers:
## • Authorization: "<REDACTED>"
## Body: form encoded data
# Perform the request and get a response back
resp <- httr2::req_perform(req)
# Extract the contents of the response body
test_group <- httr2::resp_body_json(resp)
test_group[1:5]
## $id
## [1] 69316
## 
## $name
## [1] "Test group"
## 
## $created_at
## [1] "2025-03-21T10:28:01Z"
## 
## $max_membership
## NULL
## 
## $is_public
## [1] FALSE

Here I use httr2’s “dry run” function to show that the final URL has stepped up and to the side of courses.

examiner::canvas_api("../../groups") |> 
  httr2::req_dry_run()
## GET /api/v1/groups HTTP/1.1
## Host: nmbu.test.instructure.com
## User-Agent: httr2/1.1.0 r-curl/6.2.1 libcurl/8.10.1
## Accept: */*
## Accept-Encoding: deflate, gzip
## Authorization: <REDACTED>

Cleaning up after the example:

examiner::canvas_api("../../groups", test_group$id) |> 
  httr2::req_method("DELETE") |> 
  httr2::req_perform()
## <httr2_response>
## DELETE https://nmbu.test.instructure.com/api/v1/groups/69316
## Status: 200 OK
## Content-Type: application/json
## Body: In memory (449 bytes)

Troubleshooting API errors

Error codes from the web server will trigger an R error when we use library(httr2). However, it’s often useful to see any additional information the web server might have given. We can use httr2::last_response() to get the response that failed, even if the failing R statement did not complete.

As an example, here I attempt to create a new course, even though I don’t have authorization to do so.

examiner::canvas_api("../../courses", "1") |> 
  httr2::req_perform()
## Error in `httr2::req_perform()`:
## ! HTTP 403 Forbidden.
httr2::last_response() |> 
  httr2::resp_body_json()
## $status
## [1] "unauthorized"
## 
## $errors
## $errors[[1]]
## $errors[[1]]$message
## [1] "user not authorized to perform that action"

httr2 also offers more advanced error handling, which I won’t go into here.

Pagination

API requests with many results are usually paginated, meaning that you get some of the results, plus a “Link” header telling you whether there are more items available. The docs will tell you if a given API operation returns a paginated result. For example, List quizzes in a course does.

Handling pagination is somewhat cumbersome, but examiner::canvas_paginate() will do it for you, combining the returned lists into a tibble data frame. Here I print zero rows to omit the details, but you can see the structure of the data.

examiner::canvas_api("quizzes") |> 
  examiner::canvas_paginate() |> 
  print(n = 0)
## # A tibble: 100 × 47
## # ℹ 100 more rows
## # ℹ 47 variables: id <int>, title <chr>, html_url <chr>, mobile_url <chr>,
## #   description <chr>, quiz_type <chr>, timer_autosubmit_disabled <lgl>,
## #   shuffle_answers <lgl>, show_correct_answers <lgl>, scoring_policy <chr>,
## #   allowed_attempts <int>, one_question_at_a_time <lgl>, question_count <int>,
## #   points_possible <dbl>, cant_go_back <lgl>, published <lgl>,
## #   unpublishable <lgl>, locked_for_user <lgl>, lock_info <list>, …

Possible uses of API programming

Using the API lets you combine programming with Canvas operations. Here are some of the things I’ve used it for:

  • Bulk deleting experimentally created quizzes. (A problem you’d hardly be facing if you didn’t use examiner in the first place…)
  • Automate the download and analysis of quiz results, making it easy to prepare the analysis in advance, then update with the freshest results.
  • Create “sections” containing users from self-signed-up “groups”. Sections allow teaching assistants to filter SpeedGrader to view only students from their section. However, sections do not offer self-sign-up, whereas groups do.
  • Programmatically create self-sign-up student groups with capacity matching the assigned rooms.

The main advantage over starting from scratch with e.g. Canvas APIs: Getting started, the practical ins and outs, gotchas, tips, and tricks is that authorization is a pain, and having it done automatically within a course project reduces repetitive code. Also, the httr2 library has simplified my programming enormously.

Summary of workflow

Whenever I develop something new with the Canvas API, it goes like this:

  • Figure out what I wish to do.
  • Google “canvas lms api” + whatever I want to manage, e.g. “quizzes”: canvas lms api quizzes
  • Find the right section of the docs:
    • At the top of each docs page is a list of links to e.g. list existing items, create a new one, modify an existing one, etc.
    • Below the links menu follows a definition of that kind of item: What properties does it have, and what do they mean. These properties will be named elements of R lists as you work with the API from R.
  • Carefully study the specification of each operation. For example, Get a single quiz says
    GET /api/v1/courses/:course_id/quizzes/:id
    meaning that you substitute :course_id with your course id, and :id with the identification number of an existing quiz. (Which you might have got from listing all available quizzes first.)
  • Switch to the test server by editing the CANVAS_DOMAIN environment variable in .Renviron in the RStudio project you work in, and restarting R.
  • Test the operation you wish to do, in the simplest manner possible. Our example above with zero questions in a quiz is typical.
  • Add more features to your code, remembering to back up your scripts before you revise anything that is working.