As a "Free for Teacher" user, I've used the API to great effect submitting grades into Canvas. For some context, about 1000 students take our core Comp Sci class every year, and I automated the transfer of grades from our Autograder system into Canvas for all of them.
To help other "Free for Teachers" who find this thread, go to Account > Settings > Approved Integrations, and "+ New Access Token"

I've spent a while learning how to use the API with a free account (not school-affiliated). Here's the template script I wrote that I always start with.
API_KEY = 'the value that you generated on the website'
API_URL = 'https://canvas.instructure.com/'
COURSE_NAME = "Canvas Course Name Here"
ASSN = 'the name of the assignment'
def get_course(name):
canvas = Canvas(API_URL, API_KEY)
avail_courases = canvas.get_courses()
for c in avail_courases:
if c.name == name:
print(c)
return c
return None
def get_quiz(course, assn):
quizzes = course.get_quizzes()
for quiz in quizzes:
if(quiz.title == assn):
# if(quiz.assignment_id == int(ASSN_ID)):
print(quiz)
# The quiz variable is now the assignment
return quiz
return None
if __name__ == '__main__':
# Get the canvas course
course = get_course(COURSE_NAME)
course_id = str(course.id)
if(course is None):
raise ValueError("Could not find the specified course. Check the name is correct.")
# From that course, get the desired assignment
quiz = get_quiz(course, ASSN)
if(quiz is None):
raise ValueError("Could not find the specified assignment. Check the name is correct.")
For COURSE_NAME, it's the top name on the course card on your Dashboard. In the case below "CS110 - Fall 2025", NOT "CS110 Fall 2025" (not the lack of a hyphen in the latter).

There's an important distinction between a "quiz" object and an "assignment" object in Canvas. In general, once you have the Canvas Course object, you can use it to drill down to whichever assignment/quiz you want.
There's a lot of extra steps to go from the Course object > assignment object > posting grades, but the way you set up your workflow will change based on how you structure your assignments / grades, etc. At some point, when you do want to post grades, here are the functions I wrote that call the low-level Canvas REST API, because the Python canvasapi library didn't work the way I wanted.
def post_comment_on_submission(comment, assignment_id, user_id, course_id):
"""
Posts a comment on a student's submission in canvas
Args:
comment (str): The textual comment you want to add. My script auto-formats this for me
assignment_id (str): The ID of the assignment, which you can find in the URL, too or pulled from the API.
user_id (str): the ID of the student. Also found in the URL when you look in SpeedGrader or pulled from the API.
course_id (str): the ID of the course. Also found in the URL or pulled from the API
Returns True on success, False otherwise
"""
# This works, so I'm not updating this to use the API. Just going to use the old code and the hand-jammed way of calling the Live API
params = (('access_token', API_KEY), ('comment[text_comment]', comment))
# There should be zero need for this overall comment on the assignment anymore. We'll see how PA1 in Fall 2023 goes
url = "https://canvas.instructure.com/api/v1/courses/{}/assignments/{}/submissions/{}".format(course_id, assignment_id, user_id)
# print(url,params)
result = requests.put(url,params=params)
if(result.status_code == 200):
return True
return False
def delete_comment_from_submission(assignment_id, user_id, comment_id, course_id):
"""
Deletes a comment from a submission. You have to get the comment_id from the API, which means you need to
get comments, iterate through the paginated list, find the one you want, and grab its ID.
I once posted 600 comments to 600 students, only to realize an off-by-one error put the wrong comment
on every student. So I wrote this function.
Args:
assignment_id (str): The ID of the assignment, which you can find in the URL, too or pulled from the API.
user_id (str): the ID of the student. Also found in the URL when you look in SpeedGrader or pulled from the API.
comment_id (str): the ID of the comment to delete
course_id (str): the ID of the course. Also found in the URL or pulled from the API
Returns True on success, False otherwise
"""
# There should be zero need for this overall comment on the assignment anymore. We'll see how PA1 in Fall 2023 goes
url = "https://canvas.instructure.com/api/v1/courses/{}/assignments/{}/submissions/{}/comments/{}".format(course_id, assignment_id, user_id, comment_id)
# print(url,params)
result = requests.delete(url)
if(result.status_code == 200):
return True
return False
def post_score_and_comment_on_questions(user_id, comment, scores, questions, sub, question_values, assignment_id, course_id, include_partial=False):
"""
Posts scores and comments to a submission.
Args:
user_id (str): A specific student's Canvas ID to find the appropriate submission
comment (str): A single string that will be added as a comment for the student to see on their submission
scores (list): A list that contains the scores pulled from autograder for the student's submissions. These should be in the order of the question numbers
questions (paginated list): The canvasapi object that contains the questions the student answered. Used to associate scores with question IDs
sub (paginated list): The submission object that contains the questions / scores / comments
question_values (list): The point values for each question
assignment_id (str): The id of the assignment to grade/comment
Returns True if the program succeeded in posting the scores and comments. False otherwise
Bug:
While a student is taking an exam, Canvas already creates a submission object. Therefore
if you run this code while someone is taking a test, you will find a submission with no data
that will break this function
"""
update_obj = create_questions_update_object(scores, questions, question_values, include_partial)
# This will fail if someone is taking the test; even though there is no submission, their attempt is assigned a submission number which lets me get to this point
# Despite the name of the function, we only use it to post scores to the individual questions. There are no comments in the update object
try:
update = sub.update_score_and_comments(quiz_submissions=update_obj)
except:
return False
# This posts the comment on the entire submission, not just each question.
# It's easier for instructors to skim
post_comment_on_submission(comment, assignment_id, user_id, course_id)
return True
def post_score_on_questions(scores, questions, sub, question_values, include_partial=False):
"""
Updates individual question grades on a submission WITHOUT changing the comment
Args:
scores (list): A list that contains the scores pulled from autograder for the student's submissions. These should be in the order of the question numbers
questions (paginated list): The canvasapi object that contains the questions the student answered. Used to associate scores with question IDs
sub (paginated list): The submission object that contains the questions / scores / comments
Returns True if the program succeeded in posting the scores and comments. False otherwise
"""
update_obj = create_questions_update_object(scores, questions, question_values, include_partial)
# This will fail if someone is taking the test; even though there is no submission, their attempt is assigned a submission number which lets me get to this point
try:
update = sub.update_score_and_comments(quiz_submissions=update_obj)
return True
except:
return False
def create_questions_update_object(scores, questions, question_values, include_partial):
"""
Creates an object compatible with the Canvas API to post individual question grades
Args:
scores (list): A list that contains the scores pulled from autograder for the student's submissions. These should be in the order of the question numbers
questions (paginated list): The canvasapi object that contains the questions the student answered. Used to associate scores with question IDs
question_values (list): The point values for each question
include_partial (bool): Whether or not to assign a grade to questions that did not receive 100%
Returns True if the program succeeded in posting the scores and comments. False otherwise
"""
#Get the list of questions IDs for the API
# First try for when questions is a full list
q_ids = [str(x['question_id']) for x in questions]
# temp dictionary for the final dictionary
new_dict = {}
# Remove problems that aren't 100% so that we can manually grade
# The 86.66 was to accommodate the Q7 on PA2 from Fall 2023, which we were able to autograde
# scores = [score if score == 100 else 0 for i,score in enumerate(scores)]
# Now put that dict in another dict, because API
# For the purposes of this class, we want to only grade those questions that received 100%, because
# we manually grade all others. This allows instructors to easily see what isn't graded
# by dropping scores from the eventual API call
i = 0
for spec_quids,spec_scores in zip(q_ids[:len(scores)],[score if score != "None" else 0 for score in scores]):
if(include_partial):
# Include all scores
new_dict[spec_quids]={"score":spec_scores, "comment":""}
elif(spec_scores / question_values[i] == 1):
# Include only scores that received full credit
new_dict[spec_quids]={"score":spec_scores, "comment":""}
i += 1
# And the final dict in a dict in a list, because API
update_obj = [
{
"attempt": 1,
"fudge_points": 0,
"questions": new_dict
}]
return update_obj
def update_submission_comment(user_id, comment_id, updated_comment, assignment_id, course_id):
"""
Updates a single comment on an assignment submission
Args:
user_id (str): A specific student's Canvas ID to find the appropriate submission
comment_id (str): The id number of the comment within the submission object
updated_comment (str): A single string that will be added as a comment for the student to see on their submission
assignment_id (str): The ID number of the assignment that contains the comment. Usually PA_TO_GRADE
Returns True if the program successfully updated the comment. False otherwise
"""
url = "https://canvas.instructure.com/api/v1/courses/{}/assignments/{}/submissions/{}/comments/{}".format(course_id, assignment_id,user_id, comment_id)
params = (('access_token', API_KEY), ('comment', updated_comment))
result = requests.put(url,params=params)
if(result.status_code == 200):
return True
return False