The learner routes connect the people an educator teaches in TutorFlow with your own system, such as an HR system, a student information system, or a CRM. They list a classroom's learners, report course progress and test results, invite and enroll learners, and remove them.
Learner data is personal data, so these routes are kept apart from the content routes: they need their own scopes, every call is recorded in the audit log, and responses are never cached.
Scopes
| Scope | Allows |
|---|---|
learners:read | Every learner read below, and subscribing a webhook endpoint to learner.* events with a key. |
learners:write | Inviting, enrolling, unenrolling, and removing learners. |
Both are opt-in. A key gets them only when an admin names them on the key, in Settings > Content API or with PATCH on the key. A key with no scope list (isLegacyFullAccess: true) holds every other scope, but not these two.
- A key without
learners:readgets403content_insufficient_scopewithrequiredScope: "learners:read"on every learner read, and withoutlearners:writethe same on every learner write. - A key limited to some classrooms can use the learner routes only in those classrooms; others answer
403content_classroom_not_allowed. - The learner routes need a live key. A test key gets
403content_sandbox_unsupportedon every one, and nolearner.*event is sent for the sandbox classroom. - One exception, kept for compatibility: learner names in course stats still reach a key with no scope list.
Who is a learner
A learner is a person in the classroom, identified by their TutorFlow user id. The same id appears as userId in course stats and as learnerId in webhooks and in the routes below.
Routes
Every path is under /v1/content/classrooms/{classroomId}.
| Method | Path | Scope | Success |
|---|---|---|---|
GET | /learners | learners:read | 200 with a page of learners |
GET | /learners/{learnerId} | learners:read | 200 with the learner, removed ones included |
GET | /learners/{learnerId}/progress | learners:read | 200 with { "learnerId", "data": [enrollment] } |
GET | /courses/{courseId}/enrollments | learners:read | 200 with a page of enrollments |
GET | /tests/{testId}/results | learners:read | 200 with a page of results |
GET | /tests/{testId}/results/{resultId} | learners:read | 200 with the result and its items |
POST | /learners/invitations | learners:write | 201 with { "status", "learnerId", "invitation" }. Takes Idempotency-Key. |
POST | /courses/{courseId}/enrollments | learners:write | 201 with { "data": [enrollment] }. Takes Idempotency-Key. |
DELETE | /courses/{courseId}/enrollments/{learnerId} | learners:write | 200 with { "learnerId", "courseId", "deleted": true } |
DELETE | /learners/{learnerId} | learners:write | 200 with { "id", "deleted": true } |
Lists page with a cursor, newest first: limit (default 20, values above 100 clamped to 100) and cursor (meta.nextCursor from the previous page, sent with the same filters). The response is { "data", "meta": { "limit", "hasNextPage", "nextCursor" } }, as in the audit log.
Read learners
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/learners?q=kim&limit=50" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"import { request } from './tutorflow.js'
const classroomPath = `/v1/content/classrooms/${process.env.CLASSROOM_ID}`
export async function* listLearners(filters = {}) {
let cursor = null
do {
const query = new URLSearchParams({ limit: '100', ...filters, ...(cursor ? { cursor } : {}) })
const page = await request('GET', `${classroomPath}/learners?${query}`)
yield* page.data
cursor = page.meta.hasNextPage ? page.meta.nextCursor : null
} while (cursor)
}
for await (const learner of listLearners({ status: 'active' })) {
console.log(learner.id, learner.name, learner.status)
}{
"data": [
{
"id": "00000000-0000-4000-8000-000000000f01",
"classroomId": "00000000-0000-4000-8000-000000000010",
"name": "Mina Kim",
"email": "mina.kim@example.com",
"status": "active",
"joinedAt": "2026-09-14T08:30:00.000Z"
}
],
"meta": { "limit": 50, "hasNextPage": false, "nextCursor": null }
}| Parameter | Notes |
|---|---|
status | active, inactive, or removed. Without it, active and inactive learners are listed. |
q | Part of the name, username, or email, in any case. Up to 100 characters. |
name is the learner's full name, or their username when they have none.
Progress
GET /learners/{learnerId}/progress returns one entry per course of this classroom the learner is enrolled in, newest enrollment first. With ?courseId=, it returns that course only, with a lessons list in course order, and 404 when the learner is not enrolled in it.
{
"learnerId": "00000000-0000-4000-8000-000000000f01",
"data": [
{
"learnerId": "00000000-0000-4000-8000-000000000f01",
"courseId": "00000000-0000-4000-8000-000000000201",
"enrolledAt": "2026-09-14T08:31:00.000Z",
"completedLessons": 4,
"totalLessons": 12,
"completedAt": null,
"lastActivityAt": "2026-09-30T17:02:00.000Z",
"lessons": [
{
"lessonId": "00000000-0000-4000-8000-000000000203",
"chapterId": "00000000-0000-4000-8000-000000000202",
"isCompleted": true,
"completedCount": 1,
"lastActivityAt": "2026-09-15T10:00:00.000Z"
}
]
}
]
}GET /courses/{courseId}/enrollments returns the same enrollment objects, without lessons, for every learner of a course.
totalLessonsandcompletedLessonscount the lessons in the course now, so editing the course can change them.completedAtis set the first time the learner finishes every lesson. It isnullfor courses finished before 2026-10-01;completedLessonsequal tototalLessonsstill shows they are done.
Test results
GET /tests/{testId}/results returns one result per attempt, newest first. Filter with ?learnerId=.
{
"id": "00000000-0000-4000-8000-000000000f11",
"testId": "00000000-0000-4000-8000-000000000401",
"learnerId": "00000000-0000-4000-8000-000000000f01",
"status": "submitted",
"startedAt": "2026-09-30T09:00:00.000Z",
"finishedAt": "2026-09-30T09:18:00.000Z",
"score": 70,
"maxScore": 100,
"isGraded": true,
"createdAt": "2026-09-30T09:00:01.000Z",
"updatedAt": "2026-09-30T09:18:02.000Z"
}| Field | Meaning |
|---|---|
status | in_progress or submitted. |
startedAt, finishedAt | As the learner's browser reported them. Use createdAt and updatedAt for server times. |
score, maxScore | Points earned, and the sum of the test's current item scores. |
isGraded | false while an item still waits for grading, such as an open-ended answer. |
GET /tests/{testId}/results/{resultId} adds items, in test order: testItemId, status (CORRECT, INCORRECT, PENDING, or GRADED), and score. What the learner answered is included, as answers on each item, only when you ask with ?include=answers.
Invite a learner
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/learners/invitations" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invite:hr-4711:v1" \
-d "{
\"email\": \"mina.kim@example.com\",
\"name\": \"Mina Kim\",
\"locale\": \"ko\",
\"courseIds\": [\"$COURSE_ID\"]
}"| Field | Required | Notes |
|---|---|---|
email | Yes | The person to invite. |
name | No | Up to 100 characters. Fills the learner's name only if they have none. |
locale | Yes | A BCP 47 tag for the invitation email, such as ko or pt-BR. Languages TutorFlow has no email for fall back to English. |
courseIds, testIds | No | Up to 100 each. Courses to enroll the learner in and tests to assign. Every id must belong to this classroom, otherwise 422 before anything changes. |
What happens depends on who the email belongs to:
| The email belongs to | Result | Response |
|---|---|---|
| A member of the organization | Added to the classroom at once, enrolled in courseIds, and assigned testIds. No email is sent, the same as adding a member on the Members page. | { "status": "added", "learnerId": "...", "invitation": null } |
| Anyone else | The organization's invitation email, in locale, with the classroom, courses, and tests attached. The person joins when they accept. | { "status": "invited", "learnerId": null, "invitation": { "id", "status": "pending", "expiresAt" } } |
An invitation expires after 24 hours. Inviting the same email again replaces the pending invitation, with a new expiry. When the person accepts, learner.enrolled is sent for each course. The response never repeats the email or the name.
Send an Idempotency-Key: a retry without one sends a second email.
Enroll and unenroll
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID/enrollments" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: enroll:$COURSE_ID:cohort-2026-10:v1" \
-d '{ "learnerIds": ["00000000-0000-4000-8000-000000000f01", "00000000-0000-4000-8000-000000000f02"] }'learnerIdsholds 1 to 100 ids. Every learner must already be in the classroom; otherwise422, with the others listed inerror.details. Invite people first.- Learners already enrolled are left as they are, so the call is safe to repeat. The response lists an enrollment for every learner you named.
DELETE /courses/{courseId}/enrollments/{learnerId}ends one enrollment. Progress is kept; enrolling again starts a new enrollment and sends a newlearner.enrolled.
Remove a learner
DELETE /learners/{learnerId} removes the learner from the classroom, the same as removing a member on the Members page. It also ends their course enrollments and test assignments in this classroom. Their progress and results are kept, and GET /learners/{learnerId} still returns them with status: "removed".
Learner limit
When the organization's plan has no room for another learner, adding an existing member through an invitation, or enrolling, answers 402:
{
"error": {
"code": "content_learner_limit_reached",
"message": "The organization has reached the learner limit of its plan. Upgrade the plan in TutorFlow Billing to add more learners",
"status": 402,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}Today only the Free plan has a hard limit, 10 learners. For a person who is not yet a member, the limit is checked when they accept the invitation, as in the TutorFlow app, not when it is sent.
Errors
| Status | error.code | When |
|---|---|---|
402 | content_learner_limit_reached | See Learner limit. |
403 | content_insufficient_scope | The key lacks learners:read or learners:write. |
403 | content_classroom_not_allowed | The classroom is outside the key's allowlist. |
403 | content_sandbox_unsupported | The key is a test key. |
404 | content_not_found | The learner, course, test, result, or enrollment is not in this classroom. |
409 | content_conflict | An Idempotency-Key reused with a different body, or still in progress. |
422 | content_invalid_request | A field fails validation, such as a locale that is not a BCP 47 tag, a courseIds or testIds entry from another classroom, a learnerIds entry that is not in the classroom, or a bad status, include, or cursor. error.details names the field. |
Learner webhooks
Subscribe an endpoint to these events as to any other; with a key, that needs learners:read (Webhooks). They are sent whoever caused the change: an admin in TutorFlow, an accepted invitation, a purchase, an LMS launch, or the API.
| Event | data | Sent |
|---|---|---|
learner.enrolled | learnerId, courseId, classroomId | Once per new enrollment. |
learner.course.completed | learnerId, courseId, classroomId, completedAt | The first time the learner finishes every lesson of the course, once per enrollment. |
learner.test.submitted | learnerId, testId, classroomId, resultId, submittedAt | Once per attempt, when it is submitted. Grading later does not send it again. |
Payloads carry ids and times only, never names or email addresses. Read the learner with the routes above when you need them.
Privacy
- Every call to a learner route, reads included, is recorded in the audit log:
resourceTypelearner, orcoursefor enrollments andtestfor results. - Every learner response carries
Cache-Control: no-store. - Responses carry only the fields shown here: no passwords, tokens, or phone numbers.
- Treat what you read as personal data in your own system, and give
learners:readandlearners:writeonly to the integration that needs them. See Security.