A video created through the Content API is an editable plan of scenes, narration scripts, and subtitles. It is not an mp4 yet. A new video, including an expansion's summary_video output, has no scenes until you add them, and the TutorFlow workspace lists it only once it has at least one scene. Every scene needs narration audio before it can render: generate it from each scene's script with the narration routes below, or let the render request do it. Then render with the routes below. Rendering uses the same render engine and price as the TutorFlow editor; see Pricing.
| Method | Path | Scope | Success |
|---|---|---|---|
POST | /v1/content/classrooms/{classroomId}/videos/{videoId}/narration | content:generate | 200 |
POST | /v1/content/classrooms/{classroomId}/videos/{videoId}/scenes/{sceneId}/narration | content:generate | 200 |
POST | /v1/content/classrooms/{classroomId}/videos/{videoId}/render | content:generate | 202 |
GET | /v1/content/classrooms/{classroomId}/videos/{videoId}/render | content:read | 200 |
POST | /v1/content/classrooms/{classroomId}/videos/{videoId}/render/cancel | content:generate | 200 with { "status": "ok" } |
Generate narration
A render needs narration audio on every scene. Each scene's hasNarration field (on the video read and list) says whether it has it. Narration is spoken from the scene's script with the video's voice and speed, the same way the TutorFlow editor makes it, at 1 AI Credit per scene.
Narrate every scene that has none:
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/narration" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Idempotency-Key: video:$VIDEO_ID:narration:v1"Response 200:
{
"videoId": "00000000-0000-4000-8000-000000000030",
"narratedSceneIds": ["00000000-0000-4000-8000-000000000031"],
"creditsCharged": 1
}- Scenes that already have narration are kept and cost nothing. When every scene has narration the response lists none and charges
0. - The balance for every scene is checked before the first one is narrated, so a short balance returns
402before any charge. - A scene without a
scriptcannot be narrated: the request returns400content_video_scene_script_missingwith thesceneIds, before any charge. Setscriptwith the scene PATCH first. - After you change a scene's
script, narrate that scene again withPOST .../videos/{videoId}/scenes/{sceneId}/narration. It replaces the scene's narration and charges 1 AI Credit. - Send an
Idempotency-Keyso a retried request does not narrate twice.
Start a render
curl -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Idempotency-Key: video:$VIDEO_ID:render:v1" \
-H "Content-Type: application/json" \
-d '{ "generateMissingNarration": true }'Response 202:
{
"status": "accepted",
"renderStatus": "RENDERING",
"estimatedSeconds": 180,
"totalDurationSeconds": 120,
"narratedSceneIds": ["00000000-0000-4000-8000-000000000031"],
"narrationCreditsCharged": 1
}- Send an
Idempotency-Key. A retry with the same key replays this response instead of starting and charging a second render. Use a new key, such asvideo:{videoId}:render:v2, when you mean to render again. - A video that is already rendering returns
409content_video_render_in_progress. Wait for it, or cancel it first to restart on purpose. - Every scene needs narration audio, not only a
script. With"generateMissingNarration": truethe request first narrates the scenes that have none (1 AI Credit each) and then renders; the balance check covers the narration and the render together. Without it, a video with such scenes returns400content_video_narration_missingwith theirsceneIds, before anything is charged.narratedSceneIdsandnarrationCreditsChargedreport what the request narrated. 402means the organization does not have enough AI Credits.- A render started through the API sends no email. It reports through the
video.render.completedandvideo.render.failedwebhooks, which are also sent for renders started in TutorFlow.
Read the render status
curl "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY"{
"renderStatus": "COMPLETED",
"renderError": null,
"progress": 100,
"videoUrl": "https://cdn.tutorflow.io/orgs/.../videos/rendered/...mp4?Expires=...&Signature=...",
"videoUrlExpiresAt": "2026-09-30T15:30:00.000Z",
"thumbnailUrl": "https://cdn.tutorflow.io/...",
"updatedAt": "2026-09-30T09:30:00.000Z"
}| Field | Notes |
|---|---|
renderStatus | IDLE, RENDERING, COMPLETED, or FAILED. |
renderError | On FAILED, the fixed message "The render failed. Start it again, or contact TutorFlow support if it keeps failing." Otherwise null. |
progress | 0 to 99 while rendering when the engine reports it, 100 when complete, otherwise null. |
videoUrl | A link to the mp4, usually signed. null until renderStatus is COMPLETED. |
videoUrlExpiresAt | When the signed link stops working, 6 hours after it was issued, or null when the link is not signed. Read the status again for a new link. |
thumbnailUrl | A link to the thumbnail when TutorFlow stores it for this classroom, otherwise null. |
updatedAt | When the video last changed. |
Poll every 10 to 15 seconds while renderStatus is RENDERING, or wait for the webhook. estimatedSeconds from the start response is a reasonable first wait.
Download the mp4 promptly and store your own copy if you need it for longer than the link lasts. videoUrl is the only usable link to the rendered file; the video object itself does not return the file's storage key.
Cancel
POST .../render/cancel stops a running render and answers { "status": "ok" }. The render was charged when it started, and canceling does not refund it; see Pricing.
A render loop
# curl + jq: start, then poll until the render finishes
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY" \
-H "Idempotency-Key: video:$VIDEO_ID:render:v1"
while true; do
STATUS_JSON="$(curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms/$CLASSROOM_ID/videos/$VIDEO_ID/render" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_API_KEY")"
RENDER_STATUS="$(printf '%s' "$STATUS_JSON" | jq -r '.renderStatus')"
echo "renderStatus: $RENDER_STATUS"
if [ "$RENDER_STATUS" != "RENDERING" ]; then break; fi
sleep 15
done
printf '%s\n' "$STATUS_JSON" | jq '{renderStatus, videoUrl, videoUrlExpiresAt}'The same loop in Node.js and Python is in Examples.