Resources
Content Resources

Content Resources

Create, read, update, delete, and sync TutorFlow modules, courses, videos, slides, tests, games, and simulations with a Content API key.

On this page

Every resource lives in a classroom, under /v1/content/classrooms/{classroomId}. Use these routes for content that people create, review, or manage in TutorFlow, whether your system created it or an educator did. Reads need content:read, changes need content:write, and creating or changing content you supply costs no credits.

This page explains the conventions and the request bodies. Every route with its scope and response is listed once in the API Reference. Examples use the variables from the Quickstart: TUTORFLOW_API_BASE_URL, TUTORFLOW_CONTENT_API_KEY, and CLASSROOM_ID.

Resource model

ResourceCollectionUse it for
Module/modulesOne interactive lesson that educators build and assign.
Course/coursesA curriculum of chapters and lessons.
Video/videosAn editable plan of scenes, narration scripts, and subtitles, rendered to mp4 on request.
Slides/slidesA presentation deck.
Test/testsAn assessment with items, answers, and scoring.
Game/gamesA playable web page. See Games.
Simulation/simulationsAn interactive model with controls. See Simulations.

Conventions

HTTP
Authorization: Bearer tf_content_...
Content-Type: application/json
Idempotency-Key: module:intro-to-safety:v1

Idempotency

Send an Idempotency-Key on every POST that creates something or starts an action: resource creates, module copy, video scene creation, course chapter and lesson creation, version restore, video narration and render, async game and simulation generation, test, module, course, and slide deck generation, asset upload URLs, learner invitations, and course enrollments.

  • A retry with the same key and the same body returns the first response, with the same status, and creates nothing new. A replayed response carries the header Idempotent-Replayed: true; a first response does not.
  • The same key with a different body returns 409 content_conflict. Path parameters count as part of the body, so one key cannot copy a different module or restore a different version.
  • While the first request with a key is still running, a retry returns 409. If it never finished, for example because the connection dropped, a retry can take the key over after 5 minutes.
  • A finished request replays for 24 hours. After that the key can be used again.
  • Keys are scoped to the API key that sends them, the classroom, and the kind of request, and can be up to 255 characters. Two integrations in one organization can use the same key string without replaying each other's answers. Keys first used before 2026-09-30 still match a request from any key of the same organization until they expire.
  • PATCH and DELETE do not take the header. Sending the same update twice leaves the same result. To stop an update from overwriting a change made since you read the resource, send If-Match; see Conditional requests.
  • Streamed game and simulation generation ignores the header; see Generation.
  • Expansion jobs follow the same rules, with the key in the header or in the body. See Idempotency for expansion jobs.

Build keys from your own ids and a version you control, never from the current time:

module:{externalId}:v{version}
course:{externalId}:v{version}
video:{externalId}:v{version}
slides:{externalId}:v{version}
test:{externalId}:v{version}
game:{externalId}:v{version}
simulation:{externalId}:v{version}
chapter:{courseId}:{externalChapterId}:v{version}
lesson:{chapterId}:{externalLessonId}:v{version}
scene:{videoId}:{externalSceneId}:v{version}
module:{moduleId}:copy-to-course:{courseId}:v{n}
video:{videoId}:render:v{n}
game:{gameId}:build:v{n}
game:{gameId}:restore:{version}:v{n}
expansion:{externalLevelId}:v{sourceVersion}

Raise v{n} only when you mean to repeat an action, such as rendering a video again after editing it.

Conditional requests

Single-resource reads and writes carry an ETag header. Send it back in If-None-Match on a GET to get 304 Not Modified when nothing changed, or in If-Match on a PATCH or DELETE to write only if nobody changed the resource since you read it (412 content_precondition_failed otherwise). Both are optional. See Concurrency and ETags.

Create responses

RouteSuccess
POST /modules, /courses, /videos, /slides, /tests, /games, /simulations201 with the created resource
POST /courses/{courseId}/chapters201 with the chapter
POST /courses/{courseId}/chapters/{chapterId}/lessons201 with the lesson
POST /videos/{videoId}/scenes201 with the whole video, including the new scene
POST /modules/{moduleId}/copy-to-course200 with { "lessonId", "courseLessonId", "message" }
POST /games/{gameId}/versions/{version}/restore, and the simulation equivalent200 with the restored resource
POST /videos/{videoId}/render202; see Video Rendering

Update responses

Every update returns the updated state:

RouteSuccess
PATCH /modules/{moduleId}200 with the module as GET /modules/{moduleId} returns it (with pdfUrl, lecture, and quizzes), plus the deprecated success: true.
PATCH /slides/{slideId}200 with the deck as GET /slides/{slideId} returns it (with content), plus the deprecated success: true.
PATCH /tests/{testId}200 with the test as GET /tests/{testId} returns it (with items), plus the deprecated success: true.
PATCH /courses/{courseId}200 with the updated course
PATCH /videos/{videoId}200 with the updated video
PATCH /videos/{videoId}/scenes/{sceneId}, PATCH /videos/{videoId}/scenes/reorder200 with the whole video
PATCH /games/{gameId}, PATCH /simulations/{simulationId}200 with the updated resource
PATCH /courses/{courseId}/chapters/{chapterId}200 with the chapter
PATCH /courses/{courseId}/chapters/reorder200 with the whole curriculum, as GET /chapters
PATCH /courses/{courseId}/chapters/{chapterId}/lessons/reorder200 with the chapter
PATCH /courses/{courseId}/lessons/{lessonId}200 with the lesson

Module, slide, and test updates used to answer only { "success": true }. That key is still sent, and is deprecated with a sunset of 2027-04-30; read the resource fields instead. See Versioning and Deprecation.

Delete responses

Every resource, chapter, and lesson delete returns 200 with:

JSON
{
  "id": "00000000-0000-4000-8000-000000000101",
  "deleted": true,
  "success": true
}

Read deleted. success is deprecated, and delete responses carry Deprecation, Sunset, and Link headers; see Versioning and Deprecation. A course delete can also carry chatSessionId. Deleting a video scene returns 200 with the whole video instead. A later read of a deleted resource returns 404 content_not_found.

List responses

Resource lists (modules, courses, videos, slides, tests, games, and simulations) return:

JSON
{
  "data": [{ "id": "00000000-0000-4000-8000-000000000101", "title": "Introduction to safety" }],
  "meta": {
    "page": 1,
    "take": 20,
    "itemCount": 1,
    "pageCount": 1,
    "hasPreviousPage": false,
    "hasNextPage": false
  }
}

Every resource list takes the same parameters: page, limit (default 20, values above 100 are clamped to 100), field and order (default createdAt DESC, ties broken on id), search (case-insensitive, on the title), and updatedSince. meta.take reports the page size used. The sort columns each list allows are in List pages.

HTTP
GET /v1/content/classrooms/{classroomId}/courses?limit=50&field=title&order=ASC&search=onboarding

These lists also still return deprecated keys, items and totalCount (video list: total), and say so in Deprecation, Sunset, and Link headers.

These routes do not use the envelope:

RouteReturns
GET /v1/content/classroomsA plain array of classrooms.
GET /v1/content/organizations, GET .../api-keysPlain arrays (admin session routes).
GET .../webhooksA plain array of endpoints.
GET .../webhooks/{webhookId}/deliveriesA plain array, newest first. Page with before; see Delivery log.
GET /courses/{courseId}/chapters{ "data": [chapter], "unassignedLessons": [lesson] }, the whole curriculum with no paging.

Syncing changes

Every resource list accepts updatedSince, an ISO 8601 date-time. Only rows whose updatedAt is at or after it are listed. To sync incrementally:

  1. Remember the time you start a sync run, for example 2026-09-30T00:00:00Z.

  2. List with updatedSince set to the start of your previous run, sorted oldest change first, and page until meta.hasNextPage is false:

    HTTP
    GET /v1/content/classrooms/{classroomId}/modules?updatedSince=2026-09-29T00:00:00Z&field=updatedAt&order=ASC&limit=100
  3. Store the time from step 1 for the next run. Overlapping by a few minutes is safe: skip rows whose updatedAt you already hold.

Deleted rows are not listed. Subscribe to resource.deleted to learn about deletions. resource.created, resource.updated, and resource.deleted webhooks report changes made through the Content API, by other keys, and in the TutorFlow app's editors, a few seconds after the change. See Resource events from any source. A periodic updatedSince list is still a good safety net for a receiver that was down.

Store TutorFlow ids next to your own ids, and keep the updatedAt you last saw per resource to skip unchanged ones. A complete sync loop in Node.js and Python is in Examples.

Response fields

Each resource returns exactly these fields. A field TutorFlow adds internally never appears until it is added here and to the Changelog. "Single read" means GET /{collection}/{id} only.

ResourceFields
Moduleid, classroomId, title, description, type, isPublic, videoUrl, pdfKey, isPdfDownloadEnabled, content, lectureKey, lectureVersion, status, lectureImagePrompt, metadata, slug, createdAt, updatedAt. Single read adds pdfUrl, lecture, and quizzes (id, sequence, title, question, type, options, correctAnswers, hint, explanation, showFeedback, createdAt, updatedAt).
Courseid, classroomId, title, description, thumbnail, slug, contentType, level, isPublic, visibility, createdAt, updatedAt. The single read returns the same fields. Lists add totalLearnerCount. With isIncludeStats=true, lessons, chapters, and stats are added; stats.topLearners and stats.mostBehindLearner need learners:read.
Videoid, title, description, visibility, renderStatus, renderProgress, renderPhase, thumbnailKey, bgmAudioKey, bgmVolume, bgmOffset, bgmFadeInDuration, bgmFadeOutDuration, userNarrationScript, aspectRatio, avatarId, metadata, slug, scenes, createdAt, updatedAt.
Video sceneid, order, script, displayText, narrationSource, hasNarration, videoClipSource, visualType, visualKey, visualSource, remotionTemplate, keywords, duration, audioOffset, audioDuration, videoOffset, videoDuration, visualOffset, visualDuration, visualFit, subtitleOffset, subtitleDuration, subtitles, transitionOut, transitionDuration, videoEffect, visualEffect, visualMetadata, visualAdjustments, visualOverlays, createdAt, updatedAt.
Slidesid, classroomId, title, description, outline, visibility, contentVersion, metadata, thumbnail, slug, createdAt, updatedAt. Create and single read add content, the deck body.
Testid, classroomId, name, description, thumbnail, slug, level, visibility, timeLimit, passingValue, passingValueType, periodType, periodStartDate, periodEndDate, layoutType, topicPrompt, targetPrompt, metadata, createdAt, updatedAt, and items on create and single read. Each item has id, classroomTestId, title, question, questionAudioUrl, type, options, correctAnswers, explanation, sequence, score, createdAt, updatedAt, and stats when requested.
Game, simulationSee the game object and the simulation object.

Storage references and URLs

Some fields hold a storage reference: TutorFlow's internal name for a stored file. They are opaque identifiers, not URLs. Do not build URLs from them, and do not depend on their format. They are returned because you can set some of them, and so you can tell whether a file is attached. To put your own file in one, upload it and send the returned assetKey; a key that points outside the classroom answers 400. Where a usable URL exists, it comes from another field:

FieldWhat it namesUsable URL
Module pdfKeyThe attached PDF.pdfUrl on the module's single read.
Module lectureKeyThe stored lecture body.None needed: the single read returns the body itself in lecture.
Video thumbnailKeyThe video thumbnail.thumbnailUrl on the render status, when TutorFlow stores it for the classroom.
Video bgmAudioKeyThe background music file.None through the API.
Scene visualKey, visualOverlays[].assetKeyA scene's visual and overlay images.None through the API.
Game and simulation contentKey, buildHistory[].contentKeyA built version.None needed: the single read returns the served version's HTML in content. Earlier versions are restored, not downloaded.
Game and simulation thumbnail, and contentKey and thumbnail in a build-done eventA capture of the opening screen, and the built version.None through the API.
Rendered mp4The finished video. Its storage key is not returned.videoUrl on the render status and in the video.render.completed webhook, signed for 6 hours.

Course, slide, and test thumbnail are strings set by the TutorFlow editor or by your integration and returned as stored. Module videoUrl and test item questionAudioUrl are URLs.

Not returned at all: authoring tokens, video videoKey and metadata.remotionLambda, scene ttsAudioKey and videoClipKey, slide contentKey, course authorId, chatSession, and settings (which PATCH can still set), module authorId, and learner contact details in course stats.

Modules

bash
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: module:intro-to-safety:v1" \
  -d '{
    "title": "Introduction to safety",
    "description": "A short interactive lesson for new employees.",
    "type": "markdown",
    "isPublic": false,
    "content": "<h2>Safety first</h2><p>Report every hazard you see.</p>",
    "metadata": { "externalId": "module:intro-to-safety" }
  }'

title is required. type defaults to markdown, isPublic to false, and status to ready. type takes the module editor's types: markdown, ai-tutor, chat-ai, coding-lesson, coding-test, notebook, web, flashcard, dictation, reading-comprehension, translation, shadowing, or roleplay. Any other value, exam included, answers 422 content_invalid_request, on create and on PATCH. metadata is an object you own and is returned unchanged.

Update a module with PATCH /modules/{moduleId}:

JSON
{
  "title": "Workplace safety basics",
  "content": "<h2>Safety first</h2><p>Report every hazard you see.</p>",
  "quizzes": [
    {
      "sequence": 1,
      "title": "Safety check",
      "question": "What should you do when you see a hazard?",
      "type": "select",
      "options": ["Ignore it", "Report it", "Hide it"],
      "correctAnswers": ["Report it"],
      "showFeedback": true
    }
  ]
}

Module quiz type values are select, blank, and true-false.

Copy a module into a course as a new lesson:

bash
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/modules/$MODULE_ID/copy-to-course" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: module:$MODULE_ID:copy-to-course:$COURSE_ID:v1" \
  -d "{ \"courseId\": \"$COURSE_ID\", \"sequence\": 1 }"

Courses

bash
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: course:customer-onboarding:v1" \
  -d '{
    "title": "Customer onboarding",
    "description": "Five onboarding lessons for customer success teams.",
    "contentType": "default",
    "level": "Beginner",
    "visibility": "PRIVATE"
  }'

Response 201:

JSON
{
  "id": "00000000-0000-4000-8000-000000000201",
  "classroomId": "00000000-0000-4000-8000-000000000010",
  "title": "Customer onboarding",
  "description": "Five onboarding lessons for customer success teams.",
  "thumbnail": null,
  "slug": "customer-onboarding",
  "contentType": "default",
  "level": "Beginner",
  "isPublic": true,
  "visibility": "PRIVATE",
  "createdAt": "2026-09-30T09:00:00.000Z",
  "updatedAt": "2026-09-30T09:00:00.000Z"
}
  • contentType is lowercase: default, or exam for exam-oriented courses.
  • visibility is uppercase: PUBLIC, PRIVATE, ORGANIZATION, CLASSROOM, or COURSE. It controls who can open the course.
  • isPublic is a separate, older flag stored independently of visibility, with a default of true. Read and set visibility for access.
  • A new course has no chapters or lessons. Add them with the chapter and lesson routes below.
  • GET /courses/{courseId}?isIncludeStats=true adds lessons, chapters, and stats. days sets the window of stats.dailyLearningTime: 7 (default) or 30; any other value returns 422. There is one bucket per UTC calendar day, oldest first, ending today in UTC, with date as YYYY-MM-DD.
  • Learners appear in stats by id and name only, and only for a key with learners:read. Without it, topLearners and mostBehindLearner are left out and the aggregate counts stay. See Learner data.
  • A course that does not exist, or was deleted, returns 404 content_not_found.

Update a course

PATCH /courses/{courseId} changes only the fields you send. An omitted field keeps its stored value, so a title-only update leaves visibility as it was:

bash
curl -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "title": "Customer onboarding, v2" }'
FieldNotes
title, descriptionStrings.
level, thumbnailStrings; null clears them.
slugA new slug. TutorFlow normalizes it, and adds a short suffix when another course in the classroom already uses it.
visibilityPUBLIC, PRIVATE, ORGANIZATION, CLASSROOM, or COURSE. Any other value returns 422 with error.details[0].field set to visibility.
settings.aiTutorDefaultsObject. The course's AI tutor defaults, as the TutorFlow course editor saves them.
settings.celebrationTriggerLESSON, COURSE, or NONE: when learners see a completion celebration.

Only the settings keys you send change, and they are saved where the TutorFlow editor saves them. settings is not returned in responses. The classroom always comes from the URL. The response is 200 with the updated course.

Before 2026-09-30, a course PATCH without visibility set the course to PUBLIC, and settings were not saved. If your integration updated courses before then, check that their visibility is what you intend.

Course chapters and lessons

Paths are under /v1/content/classrooms/{classroomId}/courses/{courseId}:

MethodPathBody
GET/chapters
POST/chapters{ "title", "position"? }
PATCH/chapters/reorder{ "chapterIds": [...] }
PATCH/chapters/{chapterId}{ "title"?, "position"? }
DELETE/chapters/{chapterId}Deletes the chapter's lessons too.
POST/chapters/{chapterId}/lessons{ "title", "description"?, "lecture"?, "isPublic"?, "position"? }
PATCH/chapters/{chapterId}/lessons/reorder{ "lessonIds": [...] }
GET/lessons/{lessonId}
PATCH/lessons/{lessonId}{ "title"?, "description"?, "lecture"?, "content"?, "isPublic"?, "chapterId"?, "position"? }
DELETE/lessons/{lessonId}

Add a chapter, put a lesson in it, and read the curriculum back:

bash
COURSE_PATH="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/courses/$COURSE_ID"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
 
CHAPTER_ID="$(curl -sS -X POST "$COURSE_PATH/chapters" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: chapter:$COURSE_ID:getting-started:v1" \
  -d '{ "title": "Getting started" }' | jq -r '.id')"
 
curl -sS -X POST "$COURSE_PATH/chapters/$CHAPTER_ID/lessons" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: lesson:$CHAPTER_ID:first-week:v1" \
  -d '{ "title": "Your first week", "lecture": "<p>Meet your team and set up your tools.</p>" }' > /dev/null
 
curl -sS "$COURSE_PATH/chapters" -H "$AUTH" | jq '.data[0]'

A chapter:

JSON
{
  "id": "00000000-0000-4000-8000-000000000202",
  "courseId": "00000000-0000-4000-8000-000000000201",
  "title": "Getting started",
  "slug": "1",
  "position": 1,
  "lessons": [
    {
      "id": "00000000-0000-4000-8000-000000000203",
      "chapterId": "00000000-0000-4000-8000-000000000202",
      "title": "Your first week",
      "description": "",
      "type": "markdown",
      "slug": "1",
      "position": 1,
      "isPublic": false,
      "status": null,
      "updatedAt": "2026-09-30T09:00:00.000Z"
    }
  ]
}

A lesson from GET /lessons/{lessonId}, create, and update has the same fields plus lecture, the lesson body as HTML, and content, the lesson's structured content field (for example practice code or a linked asset id). unassignedLessons in GET /chapters lists lessons that belong to no chapter, with chapterId: null.

  • position is 1-based. Omit it on create to append.
  • A lesson created through the API is hidden from learners (isPublic: false) until you send "isPublic": true on create or with the lesson PATCH.
  • To move a lesson to another chapter of the same course, send chapterId, with position for a specific place.
  • Reorder bodies must list every chapter id of the course, or every lesson id of the chapter, exactly once, otherwise 400.
  • Lessons created through the API are text lessons; a lesson's type cannot be changed through the API. Quizzes, coding problems, and translations of a lesson are not exposed.
  • lecture is HTML. Saving it marks existing translations of the lesson stale. It cannot be emptied through the API.
  • Every successful change sends a resource.updated webhook for the course.

Videos

A new video has no scenes: create it, then add scenes one at a time with POST /videos/{videoId}/scenes and fill each in with the scene PATCH. The TutorFlow workspace lists a video only once it has at least one scene, so a video you created but have not added a scene to does not appear there yet. A scene added with "insertAfterSceneId": null goes last, so its id is the last one in scenes.

bash
VIDEOS="$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos"
AUTH="Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"
 
VIDEO_ID="$(curl -sS -X POST "$VIDEOS" -H "$AUTH" -H "Content-Type: application/json" \
  -H "Idempotency-Key: video:safety-summary:v1" \
  -d '{ "title": "Safety summary", "description": "Editable video for workplace safety.", "visibility": "PRIVATE" }' \
  | jq -r '.id')"
 
add_scene() {
  curl -sS -X POST "$VIDEOS/$VIDEO_ID/scenes" -H "$AUTH" -H "Content-Type: application/json" \
    -H "Idempotency-Key: scene:$VIDEO_ID:$1:v1" \
    -d '{ "insertAfterSceneId": null }' | jq -r '.scenes[-1].id'
}
 
SCENE_ID="$(add_scene welcome)"
curl -sS -X PATCH "$VIDEOS/$VIDEO_ID/scenes/$SCENE_ID" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "script": "Welcome to the safety summary.",
    "displayText": "Safety summary",
    "remotionTemplate": "title",
    "keywords": ["safety", "onboarding"],
    "duration": 4,
    "subtitles": [{ "text": "Welcome to the safety summary.", "offset": 0, "duration": 4 }],
    "transitionOut": "fade",
    "transitionDuration": 0.45
  }' > /dev/null
 
SCENE_ID="$(add_scene report-hazards)"
curl -sS -X PATCH "$VIDEOS/$VIDEO_ID/scenes/$SCENE_ID" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{
    "script": "Report hazards immediately and follow the posted emergency process.",
    "displayText": "Report hazards immediately",
    "remotionTemplate": "keyword",
    "keywords": ["report", "hazards"],
    "duration": 5
  }' | jq '{id, scenes: [.scenes[] | {id, script, duration}]}'

The video is landscape 16:9. POST /videos takes only title, description, and visibility; send scenes with the scene routes below. A new scene starts with an empty script, the keyword template, and a 5-second duration.

Scene fieldNotes
scriptNarration text. Rendering also needs narration audio for every scene; generate it from the script with the narration routes. The scene's hasNarration says whether it has audio.
displayTextOn-screen title or quote text.
remotionTemplatetitle, keyword, quote, sketch, or none.
keywordsHighlight terms for keyword and sketch templates.
durationScene length in seconds, at least 0.5.
subtitlesOptional timed subtitles with text, offset, and duration.
transitionOutOptional transition into the next scene, for example fade. Omit it or send null for a hard cut.

Timing fields such as audioDuration, videoDuration, visualDuration, and subtitleDuration must be numbers when present; omit them rather than sending null.

Scene operations, under /videos/{videoId}:

MethodPathBodyReturns
POST/scenes{ "insertAfterSceneId": null }201 with the whole video. Read the new scene id from scenes.
PATCH/scenes/{sceneId}Any writable scene field, for example { "script", "displayText", "duration" }200 with the whole video
PATCH/scenes/reorder{ "sceneIds": [...] }200 with the whole video
DELETE/scenes/{sceneId}200 with the whole video

To produce an mp4, see Video Rendering.

Slides

bash
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/slides" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: slides:safety-training:v1" \
  -d '{
    "title": "Safety training",
    "description": "Deck for the October onboarding cohort.",
    "outline": "1. Why safety matters\n2. Reporting hazards",
    "visibility": "PRIVATE",
    "content": "<slide><h1>Safety training</h1></slide>"
  }'

title and visibility are required. Without content, the deck has no body (content: null) until you send one. content is stored as given and returned by the single read, GET /slides/{slideId}; the create response leaves it out. Update with PATCH /slides/{slideId} and any of title, description, outline, visibility, content, thumbnail, and metadata.

Tests

bash
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/tests" \
  -H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: test:safety-check:v1" \
  -d '{
    "topicPrompt": "Safety check",
    "targetPrompt": "Assess basic workplace safety knowledge.",
    "level": "MEDIUM",
    "passingValueType": "PERCENTAGE",
    "passingValue": 70
  }'

topicPrompt, level, passingValueType, and passingValue are required. A new test has no items: add them with the PATCH below, or let TutorFlow write them with POST /tests/generate.

Update a test and its items with PATCH /tests/{testId}:

JSON
{
  "name": "Safety check",
  "timeLimit": 20,
  "items": [
    {
      "question": "What should you do when you see a hazard?",
      "type": "select",
      "options": ["Report it", "Ignore it", "Hide it"],
      "correctAnswers": ["Report it"],
      "explanation": "Reporting hazards helps the team respond quickly.",
      "score": 10
    }
  ]
}

Test item type values are select, blank, open-ended, true-false, and submission.

An item id in items must belong to this test, or be one no item uses yet (you may choose ids for new items). An id that belongs to an item of another test answers 404 content_not_found, and nothing is saved. To attach audio to a question, upload it with purpose test-audio and send the key as the item's questionAudioKey.

Games and simulations

Create them with POST /games or POST /simulations (title and visibility are required), then plan and build them with the priced generation routes. See Games, Simulations, and Generation.

Checklist

  • Store TutorFlow ids next to your own ids, with the Idempotency-Key you used to create each resource.
  • Read a resource after creating it before showing it to reviewers.
  • Send If-Match on updates when educators may edit the same content in TutorFlow.
  • Review a video before rendering it.
  • Keep games and simulations PRIVATE until a person has opened the build.
  • Use a separate key for test and production.
  • Use /v1/content/** only. Never use Agent Platform keys, workspace ids, edit tokens, or platform URLs for content managed in TutorFlow.

Was this page helpful?