資源
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.

本頁內容

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, GET .../mastery/{learner}, and subscribing a webhook endpoint to learner.* events with a key (together with webhooks:manage).
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. Enrollments completed before 2026-10-01 were backfilled with the time of their last lesson completion; completedLessons equal to totalLessons also shows they are done.

Test results

GET /tests/{testId}/results returns one result per attempt, newest first. Filter with ?learnerId=. The list can also hold anonymous attempts at a PUBLIC test by someone who did not sign in. Those have isAnonymous: true, learnerId: null, and taker with the name they typed and their email (which can be null). Filtering by learnerId leaves them out. The classroom test page does not offer anonymous attempts yet: today its visitors sign in as learners to answer.

JSON
{
  "id": "00000000-0000-4000-8000-000000000f11",
  "testId": "00000000-0000-4000-8000-000000000401",
  "learnerId": "00000000-0000-4000-8000-000000000f01",
  "isAnonymous": false,
  "taker": null,
  "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
isAnonymous, takertrue and { "name", "email" } for an anonymous attempt; false and null for a learner's.
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.

Mastery

GET /mastery/{learner} (learners:read) returns a learner's mastery of each concept, built from the evaluations sent for them with submitter.learnerId and conceptTags. {learner} is that submitter.learnerId, your own id for the person, not a classroom learner id. GET /concepts (content:read) lists the concepts. The learner model is kept per organization, so every classroom answers the same.

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. There is one pending invitation per email per organization: inviting the same email again, from this or another classroom, replaces it 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.
  • The Idempotency-Key is matched on learnerIds alone, not on the course, so one key string reused for another course of the same classroom replays the first course's answer. Put the course id in the key, as above.
  • DELETE /courses/{courseId}/enrollments/{learnerId} ends one enrollment. The enrollment and its lesson progress are soft-deleted: they no longer appear in /progress or the enrollments list. Test results stay readable. 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 test results stay readable; their enrollments and lesson progress are soft-deleted and leave /progress. 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
400content_invalid_requestAn Idempotency-Key over 255 characters.
402content_learner_limit_reachedSee Learner limit. A key billed to an agent wallet also gets top-up fields such as billingSessionUrl in the body.
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, a duplicate id in learnerIds, courseIds, or testIds, or a bad status, include, or cursor. error.details names the field.
502content_internal_errorThe invitation email could not be sent. Retry later.

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. Anonymous attempts do not send it.

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 that passes the key check, reads included, is recorded in the audit log: resourceType learner, or course for enrollments and test for results. GET .../mastery/{learner} is recorded with resourceType: null. A request refused with 401, or 403 for a missing scope or classroom, is not recorded.
  • 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.

這個頁面對你有幫助嗎?