An expansion job takes one level of your existing lesson JSON and creates editable TutorFlow content from it in a classroom: an interactive module, a summary video plan, and an expanded quiz. Your JSON stays the input of record; the job result tells you which TutorFlow resources came from it.
- The shape of the JSON, and what TutorFlow reads from it, is in Source JSON Format.
- Keys and scopes are in Keys and Authentication. Creating a job needs
content:generate; reading it needscontent:read. - Prices are in Pricing.
The flow
POST /v1/content/integrations/expansionswith a classroom id, the outputs you want, and your JSON. Store the returned jobid.- Poll
GET /v1/content/integrations/expansions/{jobId}untilstatusiscompletedorfailed, or subscribe to thecontent.completedandcontent.failedwebhooks. - Read
GET /v1/content/integrations/expansions/{jobId}/resultand store each output'sresourceType,resourceId, andmanifestnext to your source ids. - Review the created module, video, and test in TutorFlow, or read and edit them with the resource routes.
Outputs
outputType | Creates | resourceType | Next step |
|---|---|---|---|
interactive_module | A module (type: "text", isPublic: false) titled after the level, whose content holds a lesson brief built from your lessons. | module | Review and finish it in the TutorFlow module editor, or edit it with PATCH /modules/{moduleId}. |
summary_video | A PRIVATE video titled after the level, with no scenes yet. It is not an mp4. | video | Add scenes with the scene routes, then render it. |
expanded_quiz | A test titled after the level, with level: "MEDIUM", a 70% passing score, your lesson brief kept as its reference material, and no items yet. | test | Review and complete its items in the TutorFlow test editor, or with PATCH /tests/{testId}. |
Outputs always run in this order. Omit requestedOutputs to get all three.
Create a job
POST /v1/content/integrations/expansions
Authorization: Bearer tf_content_...
Content-Type: application/json
Idempotency-Key: expansion:language-basics-a1:v1{
"classroomId": "00000000-0000-4000-8000-000000000010",
"requestedOutputs": ["interactive_module", "summary_video", "expanded_quiz"],
"payload": {
"language": "en",
"category": { "id": "language-basics", "title": "Language Basics" },
"level": {
"id": "level-a1",
"title": "A1 Foundations",
"lessons": [
{
"id": "lesson-greetings",
"type": "vocabulary",
"title": "Basic greetings",
"items": [{ "term": "hello", "meaning": "a greeting", "example": "Hello, Mina." }]
}
]
}
}
}| Field | Type | Required | Notes |
|---|---|---|---|
classroomId | UUID | Yes | From GET /v1/content/classrooms. |
requestedOutputs | string[] | No | Any of interactive_module, summary_video, expanded_quiz. Defaults to all three. |
payload | object | Yes | Your source JSON. See Source JSON Format. |
sourceType | string | No | Only source_json, the default. Any other value returns 400. |
idempotencyKey | string | No | Same as the Idempotency-Key header, up to 255 characters. If both are sent, the body field is used. |
Before the job is queued, TutorFlow checks that payload contains a level with at least one lesson (400 if not). Expansion jobs are free: they create editable drafts without calling a model, so no credit balance is needed.
Response 202:
{
"id": "00000000-0000-4000-8000-000000000030",
"sourceType": "source_json",
"status": "queued",
"sourceTitle": "Language Basics, A1 Foundations",
"requestedOutputs": ["interactive_module", "summary_video", "expanded_quiz"],
"classroomId": "00000000-0000-4000-8000-000000000010",
"sourceResourceId": null,
"sourceResourceType": null,
"error": null,
"completedAt": null,
"createdAt": "2026-09-30T09:00:00.000Z",
"updatedAt": "2026-09-30T09:00:00.000Z",
"outputs": [
{
"id": "00000000-0000-4000-8000-000000000031",
"outputType": "interactive_module",
"status": "queued",
"resourceType": null,
"resourceId": null,
"manifest": null,
"error": null,
"completedAt": null,
"creditAmount": 0,
"creditChargedAt": null
}
]
}The response lists one output row per requested output; the example shows one.
Idempotency for expansion jobs
Expansion jobs follow the same idempotency rules as every other create. Send the key in the Idempotency-Key header or as idempotencyKey in the body; moving it from one to the other is still the same request.
| Request | Response |
|---|---|
| Same key and same body within 24 hours | 202 with the first job in its current state (status and outputs), plus Idempotent-Replayed: true. No new job is queued. |
| Same key, different body | 409 content_conflict. |
| Same key while the first request is still being accepted | 409 ("already in progress"). Retry after a moment. |
| Same key after 24 hours | A new job. |
| Same key string in another classroom | A separate job. Keys are scoped to your API key and the classroomId. |
So:
- Use one key per source content version, for example
expansion:{sourceLevelId}:v{sourceVersion}. - When the source changes, change the key. The same key with the changed source returns
409. - Never generate the key from the current time; a retry after a timeout must send the same key.
Jobs created before 2026-09-30 with an idempotencyKey are still returned for that key, organization-wide and with no expiry, now with Idempotent-Replayed: true. Such a key that belongs to a job in a classroom your key is not allowed to use returns 409.
Poll the job
GET /v1/content/integrations/expansions/{jobId}
Authorization: Bearer tf_content_...The response has the same shape as the create response.
status | Meaning | What to do |
|---|---|---|
queued | Accepted, waiting to run. | Poll again in 3 to 5 seconds. |
processing | Creating outputs. A job that TutorFlow is trying again stays processing. | Keep polling the same job id. |
completed | Every requested output finished. | Read the result. |
failed | The job cannot finish. TutorFlow already tried it 3 times. | Read error and each outputs[].error, fix the source, and submit it with a new key. |
A job is tried up to 3 times, 5 and then 25 seconds apart. The content.failed webhook is sent once, after the final attempt; attempts that are tried again send nothing. A job whose server stops is picked up again by another server after about two minutes, and an attempt cut short by a normal server restart is not counted. In a rare case, a job interrupted by a restart right after it finished can send content.completed twice, so skip job ids you have already handled.
A copy-paste poll loop that honors 429 is in the Quickstart, and in Node.js and Python in Examples.
Read the result
GET /v1/content/integrations/expansions/{jobId}/result
Authorization: Bearer tf_content_...The result has three parts: job (the job fields), outputs (one row per output), and manifest (the job id, status, and every output's manifest in one object).
{
"job": {
"id": "00000000-0000-4000-8000-000000000030",
"sourceType": "source_json",
"status": "completed",
"sourceTitle": "Language Basics, A1 Foundations",
"requestedOutputs": ["interactive_module", "summary_video", "expanded_quiz"],
"classroomId": "00000000-0000-4000-8000-000000000010",
"error": null,
"completedAt": "2026-09-30T09:00:08.000Z",
"createdAt": "2026-09-30T09:00:00.000Z",
"updatedAt": "2026-09-30T09:00:08.000Z"
},
"outputs": [
{
"id": "00000000-0000-4000-8000-000000000031",
"outputType": "interactive_module",
"status": "completed",
"resourceType": "module",
"resourceId": "00000000-0000-4000-8000-000000000101",
"manifest": {
"outputType": "interactive_module",
"resourceType": "module",
"resourceId": "00000000-0000-4000-8000-000000000101",
"title": "A1 Foundations module",
"description": "Interactive module generated from Language Basics, A1 Foundations.",
"status": "ready",
"urls": { "publicUrl": null, "previewUrl": null },
"externalRefs": { "videoId": null, "classroomTestId": null },
"completedAt": null,
"raw": {
"id": "00000000-0000-4000-8000-000000000101",
"title": "A1 Foundations module",
"description": "Interactive module generated from Language Basics, A1 Foundations.",
"type": "text",
"status": "ready",
"slug": "a1-foundations-module",
"isPublic": false,
"classroomId": "00000000-0000-4000-8000-000000000010",
"createdAt": "2026-09-30T09:00:02.000Z",
"updatedAt": "2026-09-30T09:00:02.000Z"
}
},
"error": null,
"completedAt": "2026-09-30T09:00:03.000Z",
"creditAmount": 0,
"creditChargedAt": null
}
],
"manifest": {
"id": "00000000-0000-4000-8000-000000000030",
"status": "completed",
"sourceType": "source_json",
"sourceTitle": "Language Basics, A1 Foundations",
"outputs": [{ "outputType": "interactive_module", "resourceType": "module", "resourceId": "00000000-0000-4000-8000-000000000101" }]
}
}The example shows one output, and job and manifest.outputs are shortened. Store these fields:
| Field | Why |
|---|---|
job.id | Reconciliation and support. |
outputs[].outputType | Which output this is. |
outputs[].status | completed or failed per output. |
outputs[].resourceType, outputs[].resourceId | The created module, video, or test. Use the id with /modules/{moduleId}, /videos/{videoId}, or /tests/{testId} in the job's classroom. |
outputs[].manifest | A summary of the created resource. title is the resource's title, or a test's name for expanded_quiz. raw is a short summary of the resource as it was created: whichever of id, title, name, description, type, status, slug, isPublic, visibility, classroomId, createdAt, and updatedAt it has, with dates as ISO 8601 strings. Read the resource route for its full, current state. |
outputs[].creditAmount, outputs[].creditChargedAt | Always 0 and null: expansion outputs are not charged. Kept so existing parsers keep working. |
Errors
| Status | error.code | Cause |
|---|---|---|
400 | content_invalid_request | No level with at least one lesson in payload, an unsupported sourceType, or an idempotency key over 255 characters. |
403 | content_insufficient_scope, content_classroom_not_allowed | The key lacks content:generate, or may not use the classroom. |
404 | content_not_found | The classroom, or the job on a status or result read, was not found or is in a classroom the key cannot use. |
409 | content_conflict | The idempotency key was used with a different body, the first request with it is still being accepted, or it belongs to an older job in a classroom your key cannot use. |
422 | content_invalid_request | The body failed validation: classroomId is not a UUID, payload is not an object, or an output name is unknown. error.details names the field. A jobId in the path that is not a UUID also returns 422. |
All errors use the envelope in Errors, for example:
{
"error": {
"code": "content_invalid_api_key",
"message": "Invalid or missing Content Integration API key",
"status": 401,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}