This is the complete list of Content API routes. Other pages link here rather than repeating it. Base URL: https://api.tutorflow.io.
OpenAPI description
An OpenAPI 3.1 description of the Content API is published at:
https://tutorflow.io/resources/integrations/openapi.jsonImport it to get every route with its parameters and schemas:
- Postman: File > Import, paste the URL, and choose to generate a collection.
- Insomnia: Create > Import, choose From URL, and paste the URL.
- Bruno: Import Collection, choose OpenAPI V3 Spec, and give it the downloaded file or the URL.
Then set a collection variable for the bearer token to your tf_content_ key and the base URL to https://api.tutorflow.io. It also works with code generators such as openapi-generator and openapi-typescript.
Authentication
| Auth | Header | Used by |
|---|---|---|
| Content API key | Authorization: Bearer tf_content_... | Every route below marked with a scope. Test keys (tf_content_test_...) work only in the sandbox classroom; see Test Mode. |
| Admin session | Cookie: jwt=... | Key management, the organization list, admin webhook routes, and the legacy export. See Create a key with the API. |
Key routes and the admin session routes for keys, webhooks, and the legacy export answer errors in the Content API envelope. Only the organization list, GET /v1/content/organizations, answers in TutorFlow's standard web shape, { "statusCode", "message", "error" }.
A route whose scope is not named here uses content:read for GET and content:write for other methods. The learner routes need the opt-in learners:read or learners:write instead. A {classroomId} outside the key's allowlist returns 403 content_classroom_not_allowed before the route runs. Every id path parameter ({classroomId}, {moduleId}, {keyId}, and so on) must be a UUID; anything else returns 422 content_invalid_request. Prices are in Pricing.
Every resource list (GET on /modules, /courses, /videos, /slides, /tests, /games, /simulations) takes page, limit, field, order, search, and updatedSince; see List pages.
Response headers
| Header | On | Meaning |
|---|---|---|
Request-Id | Every response | The id TutorFlow files this request under. Also error.requestId on errors. Send X-Request-Id to choose it. See Request ids. |
X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset | Key requests counted against the key | The key's budget. See Rate limits. |
Retry-After | 429 | Seconds to wait. |
Idempotent-Replayed | A replayed idempotent response | true when the response is the stored answer to an earlier request with the same Idempotency-Key. Absent on first answers. |
Deprecation, Sunset, Link | Responses that carry deprecated fields | See Deprecation headers. |
Location, Preference-Applied | Async generation 202 | The run status URL, and respond-async (game and simulation steps only). |
ETag | Single-resource reads and writes | The resource's version. See Concurrency and ETags. |
Cache-Control: no-store | Learner routes | Learner responses must not be cached. |
Browser clients can read all of these except Location, Preference-Applied, and Cache-Control: the Content API lists them in Access-Control-Expose-Headers.
Request headers
| Header | On | Meaning |
|---|---|---|
Authorization | Every route | Bearer tf_content_... |
Idempotency-Key | Creates and POST actions marked below | Replays the first answer to a retry. See Idempotency. |
X-Request-Id | Any | Chooses the Request-Id. |
If-None-Match | Single-resource GET | 304 when the tag still matches. With fetch, also send Cache-Control: max-age=0. |
If-Match | Single-resource PATCH and DELETE | Write only if the tag still matches, otherwise 412. |
Prefer: respond-async | Game and simulation brief, build, revise | Answer 202 instead of streaming. |
Account
| Method | Path | Scope | Success |
|---|---|---|---|
GET | /v1/content/credits | content:read | 200 with the credit balance |
GET | /v1/content/classrooms | content:read | 200 with an array of { "id", "name", "slug", "locale", "isSandbox" }. A test key lists only the sandbox. |
GET | /v1/content/credits/history | content:read | 200 with a cursor page of credit rows. See Credit history. |
GET | /v1/content/audit-log | content:read | 200 with a cursor page of audit rows. See Audit log. |
Expansion
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /v1/content/integrations/expansions | content:generate | 202 with the job. Takes Idempotency-Key or body idempotencyKey; a replay answers 202 with the job's current state. |
GET | /v1/content/integrations/expansions/{jobId} | content:read | 200 with the job |
GET | /v1/content/integrations/expansions/{jobId}/result | content:read | 200 with { "job", "outputs", "manifest" } |
Modules
Under /v1/content/classrooms/{classroomId}. See Modules.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /modules | content:write | 201 with the module. Takes Idempotency-Key. |
GET | /modules | content:read | 200 with a list |
GET | /modules/{moduleId} | content:read | 200 with the module, plus pdfUrl, lecture, quizzes |
PATCH | /modules/{moduleId} | content:write | 200 with the module, as the single read returns it, plus deprecated success: true |
DELETE | /modules/{moduleId} | content:write | 200 with { "id", "deleted": true, "success": true } |
POST | /modules/{moduleId}/copy-to-course | content:write | 200 with { "lessonId", "courseLessonId", "message" }. Takes Idempotency-Key. |
POST | /modules/generate | content:generate | 202 with a generation run. Takes Idempotency-Key. See Generation runs. |
Courses
Under /v1/content/classrooms/{classroomId}. See Courses.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /courses | content:write | 201 with the course. Takes Idempotency-Key. |
GET | /courses | content:read | 200 with a list |
GET | /courses/{courseId} | content:read | 200 with the course; 404 if it does not exist. With isIncludeStats=true, learner names need learners:read; days other than 7 or 30 returns 422. |
PATCH | /courses/{courseId} | content:write | 200 with the course. Only the fields sent change. |
DELETE | /courses/{courseId} | content:write | 200 with { "id", "deleted": true, "success": true }, and chatSessionId when there is one |
POST | /courses/generate | content:generate | 202 with a generation run. Takes Idempotency-Key. |
GET | /courses/{courseId}/chapters | content:read | 200 with { "data": [chapter], "unassignedLessons": [lesson] } |
POST | /courses/{courseId}/chapters | content:write | 201 with the chapter. Takes Idempotency-Key. |
PATCH | /courses/{courseId}/chapters/reorder | content:write | 200 with the whole curriculum |
PATCH | /courses/{courseId}/chapters/{chapterId} | content:write | 200 with the chapter |
DELETE | /courses/{courseId}/chapters/{chapterId} | content:write | 200 with { "id", "deleted": true, "success": true }. Deletes its lessons. |
POST | /courses/{courseId}/chapters/{chapterId}/lessons | content:write | 201 with the lesson. Takes Idempotency-Key. |
PATCH | /courses/{courseId}/chapters/{chapterId}/lessons/reorder | content:write | 200 with the chapter |
GET | /courses/{courseId}/lessons/{lessonId} | content:read | 200 with the lesson |
PATCH | /courses/{courseId}/lessons/{lessonId} | content:write | 200 with the lesson |
DELETE | /courses/{courseId}/lessons/{lessonId} | content:write | 200 with { "id", "deleted": true, "success": true } |
Videos
Under /v1/content/classrooms/{classroomId}. See Videos and Video Rendering.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /videos | content:write | 201 with the video, with no scenes. Takes Idempotency-Key. The TutorFlow workspace lists it once it has a scene. |
GET | /videos | content:read | 200 with a list |
GET | /videos/{videoId} | content:read | 200 with the video |
PATCH | /videos/{videoId} | content:write | 200 with the video |
DELETE | /videos/{videoId} | content:write | 200 with { "id", "deleted": true, "success": true } |
POST | /videos/{videoId}/scenes | content:write | 201 with the video. Takes Idempotency-Key. |
PATCH | /videos/{videoId}/scenes/reorder | content:write | 200 with the video |
PATCH | /videos/{videoId}/scenes/{sceneId} | content:write | 200 with the video |
DELETE | /videos/{videoId}/scenes/{sceneId} | content:write | 200 with the video |
POST | /videos/{videoId}/narration | content:generate | 200 with { "videoId", "narratedSceneIds", "creditsCharged" }. Narrates scenes that have none. Takes Idempotency-Key. |
POST | /videos/{videoId}/scenes/{sceneId}/narration | content:generate | 200 with the same shape. Replaces one scene's narration. Takes Idempotency-Key. |
POST | /videos/{videoId}/render | content:generate | 202 with { "status", "renderStatus", "estimatedSeconds", "totalDurationSeconds", "narratedSceneIds", "narrationCreditsCharged" }. Body { "generateMissingNarration"?: boolean }. Takes Idempotency-Key. |
GET | /videos/{videoId}/render | content:read | 200 with the render status |
POST | /videos/{videoId}/render/cancel | content:generate | 200 with { "status": "ok" } |
Slides
Under /v1/content/classrooms/{classroomId}. See Slides.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /slides | content:write | 201 with the deck. Takes Idempotency-Key. |
GET | /slides | content:read | 200 with a list |
GET | /slides/{slideId} | content:read | 200 with the deck, including content |
PATCH | /slides/{slideId} | content:write | 200 with the deck, including content, plus deprecated success: true |
DELETE | /slides/{slideId} | content:write | 200 with { "id", "deleted": true, "success": true } |
POST | /slides/generate | content:generate | 202 with a generation run. Takes Idempotency-Key. |
Tests
Under /v1/content/classrooms/{classroomId}. See Tests.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /tests | content:write | 201 with the test and its items. Takes Idempotency-Key. |
GET | /tests | content:read | 200 with a list |
GET | /tests/{testId} | content:read | 200 with the test and its items |
PATCH | /tests/{testId} | content:write | 200 with the test and its items, plus deprecated success: true |
DELETE | /tests/{testId} | content:write | 200 with { "id", "deleted": true, "success": true } |
POST | /tests/generate | content:generate | 202 with a generation run. Takes Idempotency-Key. |
Generation runs
Under /v1/content/classrooms/{classroomId}. See Generate Tests, Modules, Courses, and Slides.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /tests/generate, /modules/generate, /courses/generate, /slides/generate | content:generate | 202 with { "runId", "status", "kind", "estimatedCredits", "statusUrl" } and a Location header. Takes Idempotency-Key. |
GET | /runs | content:read | 200 with a cursor page of runs. Filters kind and status. |
GET | /runs/{runId} | content:read | 200 with the run |
POST | /runs/{runId}/resume | content:generate | 202 with a new run that continues a failed course run. Takes Idempotency-Key. See Resume a failed course run. |
Assets
Under /v1/content/classrooms/{classroomId}. See Asset Uploads.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /assets | content:write | 201 with { "id", "purpose", "status", "assetKey", "uploadUrl", "method", "headers", "expiresAt" }. Takes Idempotency-Key. |
POST | /assets/{assetId}/complete | content:write | 200 with the upload, status: "ready" |
Learners
Under /v1/content/classrooms/{classroomId}. See Learners. Every response carries Cache-Control: no-store.
| Method | Path | Scope | Success |
|---|---|---|---|
GET | /learners | learners:read | 200 with a cursor page of learners. Filters status and q. |
GET | /learners/{learnerId} | learners:read | 200 with the learner |
GET | /learners/{learnerId}/progress | learners:read | 200 with { "learnerId", "data" }. courseId adds lessons. |
POST | /learners/invitations | learners:write | 201 with { "status", "learnerId", "invitation" }. Takes Idempotency-Key. |
DELETE | /learners/{learnerId} | learners:write | 200 with { "id", "deleted": true } |
GET | /courses/{courseId}/enrollments | learners:read | 200 with a cursor page of enrollments |
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 } |
GET | /tests/{testId}/results | learners:read | 200 with a cursor page of results. Filter learnerId. |
GET | /tests/{testId}/results/{resultId} | learners:read | 200 with the result and items. include=answers adds answers. |
Games
Under /v1/content/classrooms/{classroomId}. See Games and Generation.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /games | content:write | 201 with the game. Takes Idempotency-Key. |
GET | /games | content:read | 200 with a list |
GET | /games/{gameId} | content:read | 200 with the game, including content |
PATCH | /games/{gameId} | content:write | 200 with the game |
DELETE | /games/{gameId} | content:write | 200 with { "id", "deleted": true, "success": true } |
POST | /games/{gameId}/versions/{version}/restore | content:write | 200 with the game. Takes Idempotency-Key. |
POST | /games/{gameId}/brief | content:generate | Stream, or 202 in async mode |
POST | /games/{gameId}/build | content:generate | Stream, or 202 in async mode |
POST | /games/{gameId}/revise | content:generate | Stream, or 202 in async mode |
GET | /games/{gameId}/run | content:read | 200 with { "active", "last" }, for briefs, builds, and revisions |
Simulations
Under /v1/content/classrooms/{classroomId}. See Simulations and Generation.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /simulations | content:write | 200 with the simulation. Takes Idempotency-Key. |
GET | /simulations | content:read | 200 with a list |
GET | /simulations/{simulationId} | content:read | 200 with the simulation, including content |
PATCH | /simulations/{simulationId} | content:write | 200 with the simulation |
DELETE | /simulations/{simulationId} | content:write | 200 with { "id", "deleted": true, "success": true } |
POST | /simulations/{simulationId}/versions/{version}/restore | content:write | 200 with the simulation. Takes Idempotency-Key. |
POST | /simulations/{simulationId}/brief | content:generate | Stream, or 202 in async mode |
POST | /simulations/{simulationId}/build | content:generate | Stream, or 202 in async mode |
POST | /simulations/{simulationId}/revise | content:generate | Stream, or 202 in async mode |
GET | /simulations/{simulationId}/run | content:read | 200 with { "active", "last" }, for briefs, builds, and revisions |
Webhooks
With a key, under /v1/content/webhooks, every route needs webhooks:manage. With an admin session, the same routes are under /v1/content/organizations/{organizationId}/webhooks. See Webhooks.
| Method | Path | Success |
|---|---|---|
GET | / | 200 with an array of endpoints |
POST | / | 201 with the endpoint and its secret |
GET | /{webhookId} | 200 with the endpoint |
PATCH | /{webhookId} | 200 with the endpoint and replayedDeliveries. Takes replayFailedSince with status: "ACTIVE". |
POST | /{webhookId}/rotate-secret | 200 with the endpoint and its new secret |
POST | /{webhookId}/test | 200 with the delivery |
GET | /{webhookId}/deliveries | 200 with an array of deliveries |
POST | /{webhookId}/deliveries/{deliveryId}/redeliver | 200 with the new delivery |
DELETE | /{webhookId} | 200 with { "id", "deleted": true } |
Keys and organizations (admin session)
| Method | Path | Success |
|---|---|---|
GET | /v1/content/organizations | 200 with an array of organizations you can manage |
GET | /v1/content/organizations/{organizationId}/api-keys | 200 with an array of key metadata, including monthlyCreditLimit and spentThisMonth |
POST | /v1/content/organizations/{organizationId}/api-keys | 201 with the key, including the one-time apiKey. mode: "test" makes a test key. |
PATCH | /v1/content/organizations/{organizationId}/api-keys/{keyId} | 200 with the key metadata. Changes name, scopes, classroomIds, rateLimitPerMinute, expiresAt, or monthlyCreditLimit in place; 409 content_api_key_not_active for a revoked or expired key. |
POST | /v1/content/organizations/{organizationId}/api-keys/{keyId}/rotate | 200 with the new key and previousKeyExpiresAt |
POST | /v1/content/organizations/{organizationId}/api-keys/{keyId}/revoke | 200 with no body |
DELETE | /v1/content/organizations/{organizationId}/api-keys/{keyId} | 200 with { "id", "deleted": true }. Removes a revoked or expired key from the list; 409 content_api_key_active for a key that still works. |
GET | /v1/content/organizations/{organizationId}/credits/history | 200 with the organization's credit rows, as the key route returns them |
GET | /v1/content/organizations/{organizationId}/audit-log | 200 with the organization's audit rows, as the key route returns them, plus actorName on admin rows |
Legacy standalone lesson export (admin session)
POST /v1/content/classrooms/{classroomId}/integrations/exports
Cookie: jwt=...{ "lessonId": "00000000-0000-4000-8000-000000000101", "idempotencyKey": "export:00000000-0000-4000-8000-000000000101:v1" }This route takes an admin session, not a tf_content_ key, and answers errors in the Content API envelope. It records an existing standalone lesson as a completed job and returns 200 with { "job", "outputs", "manifest" }. New integrations do not need it: read the same lesson with GET /modules/{moduleId} and a key.
Status codes
| Code | Meaning |
|---|---|
200 | Read, update, delete, action (including a module copied to a course and a restored game or simulation version), or an opened generation stream (whatever event ends it). |
201 | Created: a resource, chapter, lesson, scene, upload URL, learner invitation, or enrollments. |
202 | Accepted: an expansion job, a video render, async game or simulation generation, or a test, module, course, or slide deck generation run. |
304 | Not modified: the If-None-Match tag still matches. No body. |
400 | The request is well formed but not allowed in the current state. |
401 | Missing or invalid key, or no admin session on a session route. |
402 | Not enough AI Credits, a failed payment, the key's monthly credit limit reached, or the plan's learner limit reached. |
403 | Missing scope, classroom not allowed, or not permitted. |
404 | Not found, or deleted. |
409 | Idempotency or state conflict, content_api_key_not_active when changing a revoked or expired key, or content_asset_not_uploaded. |
412 | If-Match did not match the current version (content_precondition_failed). |
413 | Request body over 100 KB. |
422 | Path, body, or query validation failed, including an id that is not a UUID; error.details names each field. |
429 | Rate limit exceeded, too many failed authentications from the address, or the organization's daily upload limit reached; wait for Retry-After. |
500 | Unexpected server error. |
Every code, error.code, and the action to take is in Errors.