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:
- Canvas API reference
- httr2 and its Get Started guide.
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.