Resources
Learners

Learners

Read a classroom's learners, their course progress and test results, invite and enroll learners, and receive learner webhooks, with the opt-in learner scopes.

On this page

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

ScopeAllows
learners:readEvery learner read below, and subscribing a webhook endpoint to learner.* events with a key.
learners:writeInviting, 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:read gets 403 content_insufficient_scope with requiredScope: "learners:read" on every learner read, and without learners:write the same on every learner write.
  • A key limited to some classrooms can use the learner routes only in those classrooms; others answer 403 content_classroom_not_allowed.
  • The learner routes need a live key. A test key gets 403 content_sandbox_unsupported on every one, and no learner.* 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}.

MethodPathScopeSuccess
GET/learnerslearners:read200 with a page of learners
GET/learners/{learnerId}learners:read200 with the learner, removed ones included
GET/learners/{learnerId}/progresslearners:read200 with { "learnerId", "data": [enrollment] }
GET/courses/{courseId}/enrollmentslearners:read200 with a page of enrollments
GET/tests/{testId}/resultslearners:read200 with a page of results
GET/tests/{testId}/results/{resultId}learners:read200 with the result and its items
POST/learners/invitationslearners:write201 with { "status", "learnerId", "invitation" }. Takes Idempotency-Key.
POST/courses/{courseId}/enrollmentslearners:write201 with { "data": [enrollment] }. Takes Idempotency-Key.
DELETE/courses/{courseId}/enrollments/{learnerId}learners:write200 with { "learnerId", "courseId", "deleted": true }
DELETE/learners/{learnerId}learners:write200 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

bash
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/learners?q=kim&limit=50" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
JSON
{
  "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 }
}
ParameterNotes
statusactive, inactive, or removed. Without it, active and inactive learners are listed.
qPart 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.

JSON
{
  "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.

  • totalLessons and completedLessons count the lessons in the course now, so editing the course can change them.
  • completedAt is set the first time the learner finishes every lesson. It is null for courses finished before 2026-10-01; completedLessons equal to totalLessons still shows they are done.

Test results

GET /tests/{testId}/results returns one result per attempt, newest first. Filter with ?learnerId=.

JSON
{
  "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"
}
FieldMeaning
statusin_progress or submitted.
startedAt, finishedAtAs the learner's browser reported them. Use createdAt and updatedAt for server times.
score, maxScorePoints earned, and the sum of the test's current item scores.
isGradedfalse 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

bash
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\"]
  }"
FieldRequiredNotes
emailYesThe person to invite.
nameNoUp to 100 characters. Fills the learner's name only if they have none.
localeYesA BCP 47 tag for the invitation email, such as ko or pt-BR. Languages TutorFlow has no email for fall back to English.
courseIds, testIdsNoUp 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 toResultResponse
A member of the organizationAdded 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 elseThe 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

bash
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"] }'
  • learnerIds holds 1 to 100 ids. Every learner must already be in the classroom; otherwise 422, with the others listed in error.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 new learner.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:

JSON
{
  "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

Statuserror.codeWhen
402content_learner_limit_reachedSee Learner limit.
403content_insufficient_scopeThe key lacks learners:read or learners:write.
403content_classroom_not_allowedThe classroom is outside the key's allowlist.
403content_sandbox_unsupportedThe key is a test key.
404content_not_foundThe learner, course, test, result, or enrollment is not in this classroom.
409content_conflictAn Idempotency-Key reused with a different body, or still in progress.
422content_invalid_requestA 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.

EventdataSent
learner.enrolledlearnerId, courseId, classroomIdOnce per new enrollment.
learner.course.completedlearnerId, courseId, classroomId, completedAtThe first time the learner finishes every lesson of the course, once per enrollment.
learner.test.submittedlearnerId, testId, classroomId, resultId, submittedAtOnce 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: resourceType learner, or course for enrollments and test for 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:read and learners:write only to the integration that needs them. See Security.

Was this page helpful?