The envelope
Every error from a route that takes a tf_content_ key has this shape:
{
"error": {
"code": "content_invalid_api_key",
"message": "Invalid or missing Content Integration API key",
"status": 401,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}Branch on error.code. message is for people and can change. requestId is the same value as the Request-Id response header; see Request ids. Some errors add fields: details on validation errors, allowedValues on an unknown sort field, requiredScope on scope errors, retryAfterSeconds on 429, currentETag on 412, limit, spent, requested, and resetsAt on a key's monthly credit limit, and the fields listed with each upload error in Error codes. A path that matches no route under /v1/content also returns this envelope, with 404 content_not_found.
The admin session routes for keys, webhooks, and the legacy export use the same envelope. So does a body refused before it reaches a route: a body over 100 KB answers 413 content_payload_too_large, and malformed JSON answers 400 content_invalid_request, both with a Request-Id. One response does not use it: the organization list, GET /v1/content/organizations, answers in TutorFlow's standard web shape, { "statusCode", "message", "error" }.
Request ids
Every response from /v1/content/**, success or error, carries a Request-Id header, and every error body repeats it as error.requestId. TutorFlow files its server logs and audit log under this id, so it is the fastest way for support to find your request.
To choose the id yourself, for example to match your own trace ids, send X-Request-Id (or Request-Id). It is kept when it is 1 to 128 characters of letters, digits, ., _, and -; otherwise TutorFlow generates a UUID. When both headers are sent, X-Request-Id wins.
GET /v1/content/credits
Authorization: Bearer tf_content_...
X-Request-Id: cms-sync-2026-09-30-000142
HTTP/1.1 200 OK
Request-Id: cms-sync-2026-09-30-000142Log Request-Id next to the status and error.code of every failed call. Browser clients can read it: the Content API lists it in Access-Control-Expose-Headers.
What to do for each status
| Status | Retry? | Action |
|---|---|---|
400 | No | Fix the request or the resource's state; see the table below. |
401 | No | Replace the key. It is missing, mistyped, revoked, or expired. |
402 | No, not until billing or the limit changes | Stop. For content_payment_required or content_payment_failed, add credits or fix the failed payment in TutorFlow Billing. For content_key_budget_exceeded, wait until error.resetsAt or ask an admin to raise the key's limit. For content_learner_limit_reached, the plan has no room for another learner. Then retry with the same Idempotency-Key. |
403 | No | Use a key with the scope named in error.requiredScope, or one allowed for the classroom. |
404 | No | The id is wrong, deleted, or in a classroom the key cannot see. A test key sees only the sandbox classroom, and a live key never sees it. |
412 | After reading again | The resource changed since the If-Match tag you sent. Read it again, reapply your change, and retry with the new ETag. See Concurrency and ETags. |
409 | Read, then decide | Read the resource, job, or run first. If an earlier request with the same key is still in progress, wait and retry the same request. If a build or render is already running, wait for it. If the key was used with a different body, use a new key. content_api_key_not_active means the API key you tried to change is revoked or expired; create a new one. |
413 | No | Send a smaller body. |
422 | No | Fix the fields listed in error.details. |
429 | Yes | Wait Retry-After seconds, then retry with the same Idempotency-Key. If it follows a run of 401s, fix the key first: the address is blocked for failed authentication. content_asset_daily_limit_reached can mean hours; see Upload limits. |
500, 502, 503, 504, network error, timeout | Yes | Retry with exponential backoff and the same Idempotency-Key, so a request that did succeed is not repeated. |
A Node.js and Python request() wrapper that implements this table is in Examples.
400 and 422
Both carry error.code: "content_invalid_request". The status tells you which check failed.
422: the request does not match its schema. A path parameter, body field, or query field has the wrong type, is missing, is out of range, or has an unknown value. error.details has one entry per field:
{
"error": {
"code": "content_invalid_request",
"message": "Validation failed: classroomId must be a UUID",
"details": [
{ "field": "classroomId", "message": "classroomId must be a UUID", "constraints": ["isUuid"] }
],
"status": 422
}
}Id path parameters are checked the same way. Every parameter named id or ending in Id (classroomId, courseId, chapterId, lessonId, sceneId, webhookId, deliveryId, keyId, organizationId, and the resource ids) must be a hyphenated UUID. The check runs after authentication, so a bad key still gets 401 first:
GET /v1/content/classrooms/abc/courses
Authorization: Bearer tf_content_...
HTTP/1.1 422 Unprocessable Entity
Request-Id: 3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11{
"error": {
"code": "content_invalid_request",
"message": "classroomId must be a UUID",
"details": [{ "field": "classroomId", "message": "must be a UUID" }],
"status": 422,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}A value TutorFlow cannot parse anywhere else in the request, for example a malformed id inside a body that no field check covers, also answers 422 content_invalid_request, with the message "A value in the request is not in the expected format".
Examples of 422:
- An id path parameter that is not a UUID, or
classroomIdin a body that is not one. payloadis not an object, or an unknown output inrequestedOutputs.- List queries:
limit,take, orpagebelow 1 or not an integer, afieldoutside the list's sort columns (witherror.allowedValues), anorderother thanASCorDESC, asearchover 200 characters, or anupdatedSincethat is not an ISO 8601 date-time. See List pages. dayson course stats other than7or30.- A course
visibilityoutsidePUBLIC,PRIVATE,ORGANIZATION,CLASSROOM,COURSE. - A revision
feedbackover 2,000 characters, or a chaptertitleover 255 characters. - Generation bodies: a test
itemTypeswhose length is notitemCount, a slide deckslideCountoutside 5 to 20, or athemeIdortextLengththat is not listed.
Fields the schema does not know are dropped silently, not rejected.
An unknown sort field lists the columns that work:
{
"error": {
"code": "content_invalid_request",
"message": "field must be one of: createdAt, updatedAt, title",
"details": [{ "field": "field", "message": "field must be one of: createdAt, updatedAt, title", "constraints": ["isIn"] }],
"allowedValues": ["createdAt", "updatedAt", "title"],
"status": 422,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}400: the request is well formed but cannot be carried out. There is no details; read message.
| Route | 400 when |
|---|---|
POST /v1/content/integrations/expansions | payload has no level with at least one lesson, or sourceType is not source_json. |
Any route taking Idempotency-Key, expansions included | The key is longer than 255 characters. |
Game or simulation build, revise | Building without a brief, or revising something never built. |
POST /videos/{videoId}/render | A scene has no narration audio (content_video_narration_missing, with sceneIds). Send generateMissingNarration: true or call the narration route. |
POST /videos/{videoId}/narration, .../scenes/{sceneId}/narration | A scene to narrate has no script (content_video_scene_script_missing, with sceneIds). |
| Chapter and lesson reorder | The list does not name every chapter of the course, or every lesson of the chapter, exactly once. |
| Webhook create or update | The URL is not https, or does not resolve to a public address. |
| Key create or change (admin session) | An empty scopes or classroomIds list, a classroom outside the organization, classroomIds with webhooks:manage, or expiresAt in the past. |
Any field that holds a storage key: thumbnails, video thumbnailKey and bgmAudioKey, scene visual and overlay keys, test questionAudioKey, module pdfKey, videoUrl, and lectureKey | The key points outside this classroom ("pdfKey must reference an asset stored in this classroom"), or is an upload made for another purpose ("thumbnail takes a Content API upload with purpose thumbnail, not test-audio"). A value the resource already holds can be saved again. |
POST /assets | The content type, size, or file name extension does not fit the purpose (content_asset_type_not_allowed, content_asset_too_large, content_asset_extension_mismatch). |
Webhook update with replayFailedSince | The time is in the future or more than 7 days ago, or the endpoint stays DISABLED. |
Error codes
| Code | Status | Meaning |
|---|---|---|
content_invalid_request | 400, 422 | See 400 and 422. |
content_invalid_api_key | 401 | The bearer token is missing, not a tf_content_ key, revoked, or expired. |
content_session_required | 401 | An admin session route (key and webhook management, the legacy export) was called without a signed-in admin. An API key does not work on these routes. |
content_payment_required | 402 | Not enough availableCredit for a generation step or a render. |
content_payment_failed | 402 | The organization has a failed payment. |
content_key_budget_exceeded | 402 | The action would take the key over its monthly AI Credit limit. The body adds limit, spent, requested, and resetsAt. See Monthly credit limit. |
content_learner_limit_reached | 402 | The organization's plan has no room for another learner. See Learners. |
content_asset_type_not_allowed | 400 | The upload's contentType is not allowed for its purpose. The body adds allowedContentTypes. |
content_asset_too_large | 400 | The upload's contentLength is over its purpose's limit. The body adds maxBytes. |
content_asset_extension_mismatch | 400 | The upload's filename extension does not fit its contentType. The body adds acceptedExtensions. |
content_insufficient_scope | 403 | The key lacks error.requiredScope, or a classroom-limited key called a webhook route. Subscribing to learner.* events with a key needs learners:read. |
content_sandbox_unsupported | 403 | A test key called a learner route, or a test endpoint tried to subscribe to learner.* events. Use a live key. See Test Mode. |
content_classroom_not_allowed | 403 | The key's classroom allowlist does not include the classroom in the URL. |
content_forbidden | 403 | The organization has no active admin to own resources a key creates, or, on an admin session route, the signed-in user is not an active admin of the organization. Also a key limited by scopes or classrooms asking for another key's rows in the credit history or audit log. |
content_payload_too_large | 413 | The body is over 100 KB. Send a smaller body. |
content_not_found | 404 | The job, run, classroom, resource, learner, version, or route was not found, or was deleted. Also a test items[].id that belongs to another test; nothing is saved. |
content_asset_not_found | 404 | No upload with this id in this classroom. |
content_conflict | 409 | An Idempotency-Key reused with a different body or still in progress, or a game or simulation build already running. |
content_video_render_in_progress | 409 | The video is already rendering. |
content_run_not_resumable | 409 | POST .../runs/{runId}/resume on a run that did not fail, is not a course run, failed before its course existed, or was started with a key of the other mode. Start a new generation. |
content_run_already_resumed | 409 | The failed run was already resumed. The body adds resumedByRunId; follow that run. |
content_asset_not_uploaded | 409 | complete was called before anything was uploaded to the URL. |
content_precondition_failed | 412 | If-Match did not match. The body adds currentETag, or null when the resource does not exist or the key cannot see it. |
content_asset_mismatch | 422 | The uploaded file differs from the declared size or type. It was deleted; ask for a new upload URL. |
content_api_key_active | 409 | DELETE on an API key that still works (admin session). Revoke it first. |
content_api_key_not_active | 409 | PATCH on an API key that is revoked or expired (admin session). Create a new key. |
content_rate_limit_exceeded | 429 | The key used its per-minute budget, or the address failed authentication too often. See Limits. |
content_asset_daily_limit_reached | 429 | The organization reached its upload limit for the last 24 hours. The body adds uploadLimit, byteLimit, uploadsUsed, bytesUsed, and retryAfterSeconds. |
content_sandbox_spend_blocked | 500 | Something tried to charge AI Credits while a test key acted, and TutorFlow refused instead of charging. It should never happen; send the Request-Id to support. |
content_internal_error | 500 | Unexpected server error. The message is always "An unexpected error occurred"; details stay in TutorFlow's logs, filed under error.requestId. |
Scope errors name the missing scope:
{
"error": {
"code": "content_insufficient_scope",
"message": "This API key lacks the content:generate scope",
"requiredScope": "content:generate",
"status": 403
}
}Errors after a request was accepted
Some work fails after the HTTP response:
| Work | Where the failure arrives |
|---|---|
| Expansion job | status: "failed" on the job, with error and each outputs[].error, and a content.failed webhook after the final attempt. |
| Test, module, or course generation run | status: "failed" and error on GET .../runs/{runId}, and a *.generation.failed webhook. |
| Streamed game or simulation step | An error event on the stream, after a 200. See Generation. |
| Async game or simulation step | A *.failed webhook, and last on GET .../run, including runs whose server stopped. |
| Video render | renderStatus: "FAILED" on the render status, and a video.render.failed webhook. |
A failed async run releases its Idempotency-Key, so a retry with the same key starts a new run. For a failed expansion job, fix the source JSON or the requested outputs, then submit it with a new Idempotency-Key; the old key returns the old job. For a build that failed with violations, change the brief or metadata before building again.
Contacting support
Send these, and never the full key:
| Field | Notes |
|---|---|
Request-Id | From the response header or error.requestId. With it, support finds the request directly. |
keyPrefix | From the key list or Settings. |
| Route and method | For example POST /v1/content/integrations/expansions. |
| Time, with time zone | |
Idempotency-Key | If you sent one. |
| Response status and body | The whole error object. |
Job id, or game or simulation id and contentVersion | For expansion, build, and restore issues. |
| Webhook delivery id | For delivery issues. |