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. It is an editable plan, not an mp4. | video | Review the scenes, then render it. |
expanded_quiz | A test titled after the level, with level: "MEDIUM", a 70% passing score, and your lesson brief kept as its reference material. | 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. | Keep polling the same job id. |
completed | Every requested output finished. | Read the result. |
failed | The job cannot finish. TutorFlow already retried it internally. | Read error and each outputs[].error, fix the source, and submit it with a new key. |
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", "type": "text", "status": "ready" }
},
"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. raw is the resource as it was created; read the resource route for its 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"
}
}