Grades API reference
There is one grade endpoint, and you never build its URL: it arrives in every launch as the grade.submit claim.
POST <grade.submit>
Content-Type: application/json
Call the URL exactly as it arrives and treat it as opaque — the only segment you may change is the member, to grade another student in the same course.
grade.submit is null when the launch has no gradebook column to post to. There is nothing to call in that case; see when you can send a grade.
Authentication
Either credential, on whichever URLs it is valid for (details):
Authorization: Bearer <api.token> # as the launching user; any grade.submit URL
Authorization: Bearer ltiaas_<app-id>_<secret> # as your tool’s server; /contexts/… URLs only
Publish a score
Every call publishes to the LMS and records one attempt. Nothing is updated in place — sending again is a new attempt and a new call to the LMS. See every submission is an attempt.
Request body
scorenumberrequiredThe score as a fraction from 0.0 to 1.0. Outside that range it's a 422.
max_scorenumberOptional. The points the gradebook column should be worth. Sending it updates the LMS gradebook column to this value before the score is published, so 0.85 renders as 42.5 out of a max_score of 50. Must be greater than 0. Omit it to leave the column at whatever the instructor (or your points declaration) set.
Sending max_score requires the school's LMS developer key to grant the AGS line item scope on top of the score scope. On a deployment that only grants score, the attempt comes back status: "failed" with the reason in data.error — loudly, rather than silently mis-scaling. If you don't need to change the column's value, don't send the field.
commentstringdefault: A comment published to the LMS alongside the score.
activity_progressstringdefault: CompletedCompleted or Submitted — whether the student has finished the activity.
grading_progressstringdefault: FullyGradedFullyGraded or Pending — whether this score is final.
Example
- curl
- Python
- Node
# $SUBMIT_URL is grade.submit from the launch
curl -X POST "$SUBMIT_URL" \
-H "Authorization: Bearer ltiaas_myappid_mysecret" \
-H "Content-Type: application/json" \
-d '{
"score": 0.85,
"comment": "Nice work!"
}'
import requests
submit_url = launch["grade"]["submit"]
if submit_url: # null when this placement can't take a score
resp = requests.post(
submit_url,
headers={"Authorization": "Bearer ltiaas_myappid_mysecret"},
json={
"score": 0.85,
"comment": "Nice work!",
},
)
resp.raise_for_status()
attempt = resp.json()
if attempt["status"] != "success":
log.warning("gradebook rejected the score: %s", attempt["data"]["error"])
const submitUrl = launch.grade.submit
if (submitUrl) { // null when this placement can't take a score
const resp = await fetch(submitUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ltiaas_myappid_mysecret',
'Content-Type': 'application/json',
},
body: JSON.stringify({
score: 0.85,
comment: 'Nice work!',
}),
})
const attempt = await resp.json()
if (attempt.status !== 'success') {
console.warn('gradebook rejected the score:', attempt.data.error)
}
}
Response — 200
The attempt that was just recorded.
{
"id": "Igp8dEx",
"status": "success",
"score": 0.85,
"max_score": null,
"activity_progress": "Completed",
"grading_progress": "FullyGraded",
"submitted": "2026-09-21T18:10:00+00:00",
"member": "Im8mBr42",
"location": "Il0c8n",
"app": "I4ppXy",
"context": "IcT91mBxze",
"data": {
"comment": "Nice work!",
"response": {
"status": 200,
"body": ""
},
"error": "",
"source_type": "api_key",
"source_id": ""
}
}
idstringThe attempt's ID. Useful in a support request; there is no endpoint that reads it back.
statusstringsuccess or failed — check this on every call. A rejected score still returns 200; the failure is reported here, not as an HTTP error.
scorenumberThe fraction you sent (0.0–1.0).
max_scorenumber | nullThe max_score you sent, or null if you sent none.
submittedstringThe instant this score was published, which is also the timestamp the LMS was given. Each attempt carries a later one, which is what lets a regrade supersede an earlier score.
memberstringThe student this score is for — matches user.member from their launch.
locationstringVoshi's ID for the location the score is for — matches location.id from the launch.
appstringYour tool's ID.
contextstringThe course, matching the launch's context.
dataobjectWhat happened on the way to the gradebook.
The score payload Voshi sent the LMS is deliberately withheld from this response: it carries the student's LMS identifier, which is never handed to a tool.
Failed attempts vs. errors
The two are different, and the split is deliberate:
- An LMS refusal is a
200withstatus: "failed". The call was valid, it was made, the gradebook said no.data.errorsays why, anddata.responsecarries the LMS's own words. The commonest case is a placement with no gradebook column — publish to a location that was never deep-linked in that course and you get a failed attempt readinglocation has no line item. - A bad request is an HTTP error and nothing is published or recorded.
Errors
| Status | Message | Cause |
|---|---|---|
422 | score must be between 0 and 1 | score outside 0.0–1.0. |
422 | max_score must be greater than 0 | max_score sent as zero or negative. |
422 | activity_progress must be one of [...] | Value not Submitted or Completed. |
422 | grading_progress must be one of [...] | Value not FullyGraded or Pending. |
422 | grade passback is only allowed for assessment locations | The location isn't type assessment. A launch of one never offers a grade.submit URL in the first place. |
401 | Missing Bearer API key / Invalid API key / … | No credential, an unknown or rotated API key, a suspended tool, or an API key on a /account/v1/users/… URL — see Errors. |
403 | API key is for tool …, not tool … | The key belongs to a different tool than the URL names. |
403 | tool … has no placement of location … in context … | An API key used for a course your tool isn't placed in. |
403 | — | A session that isn't allowed to grade this member: a student's token aimed at anyone but themselves. |
Reading attempts back
There is no endpoint for it. The response to each POST is the record, so log what you need from it.
A tool's full passback history is visible in the dashboard under Tools → your tool → Grades, and instructors can see their own course's.