Resources
Content API Errors

Content API Errors

The error envelope, every error code, the difference between 400 and 422, and what to do for each status.

On this page

The envelope

Every error from a route that takes a tf_content_ key has this shape:

JSON
{
  "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.

HTTP
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-000142

Log 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

StatusRetry?Action
400NoFix the request or the resource's state; see the table below.
401NoReplace the key. It is missing, mistyped, revoked, or expired.
402No, not until billing or the limit changesStop. 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.
403NoUse a key with the scope named in error.requiredScope, or one allowed for the classroom.
404NoThe 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.
412After reading againThe 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.
409Read, then decideRead 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.
413NoSend a smaller body.
422NoFix the fields listed in error.details.
429YesWait 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, timeoutYesRetry 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:

JSON
{
  "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:

HTTP
GET /v1/content/classrooms/abc/courses
Authorization: Bearer tf_content_...
 
HTTP/1.1 422 Unprocessable Entity
Request-Id: 3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11
JSON
{
  "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 classroomId in a body that is not one.
  • payload is not an object, or an unknown output in requestedOutputs.
  • List queries: limit, take, or page below 1 or not an integer, a field outside the list's sort columns (with error.allowedValues), an order other than ASC or DESC, a search over 200 characters, or an updatedSince that is not an ISO 8601 date-time. See List pages.
  • days on course stats other than 7 or 30.
  • A course visibility outside PUBLIC, PRIVATE, ORGANIZATION, CLASSROOM, COURSE.
  • A revision feedback over 2,000 characters, or a chapter title over 255 characters.
  • Generation bodies: a test itemTypes whose length is not itemCount, a slide deck slideCount outside 5 to 20, or a themeId or textLength that is not listed.

Fields the schema does not know are dropped silently, not rejected.

An unknown sort field lists the columns that work:

JSON
{
  "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.

Route400 when
POST /v1/content/integrations/expansionspayload has no level with at least one lesson, or sourceType is not source_json.
Any route taking Idempotency-Key, expansions includedThe key is longer than 255 characters.
Game or simulation build, reviseBuilding without a brief, or revising something never built.
POST /videos/{videoId}/renderA 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}/narrationA scene to narrate has no script (content_video_scene_script_missing, with sceneIds).
Chapter and lesson reorderThe list does not name every chapter of the course, or every lesson of the chapter, exactly once.
Webhook create or updateThe 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 lectureKeyThe 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 /assetsThe 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 replayFailedSinceThe time is in the future or more than 7 days ago, or the endpoint stays DISABLED.

Error codes

CodeStatusMeaning
content_invalid_request400, 422See 400 and 422.
content_invalid_api_key401The bearer token is missing, not a tf_content_ key, revoked, or expired.
content_session_required401An 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_required402Not enough availableCredit for a generation step or a render.
content_payment_failed402The organization has a failed payment.
content_key_budget_exceeded402The 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_reached402The organization's plan has no room for another learner. See Learners.
content_asset_type_not_allowed400The upload's contentType is not allowed for its purpose. The body adds allowedContentTypes.
content_asset_too_large400The upload's contentLength is over its purpose's limit. The body adds maxBytes.
content_asset_extension_mismatch400The upload's filename extension does not fit its contentType. The body adds acceptedExtensions.
content_insufficient_scope403The 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_unsupported403A 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_allowed403The key's classroom allowlist does not include the classroom in the URL.
content_forbidden403The 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_large413The body is over 100 KB. Send a smaller body.
content_not_found404The 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_found404No upload with this id in this classroom.
content_conflict409An Idempotency-Key reused with a different body or still in progress, or a game or simulation build already running.
content_video_render_in_progress409The video is already rendering.
content_run_not_resumable409POST .../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_resumed409The failed run was already resumed. The body adds resumedByRunId; follow that run.
content_asset_not_uploaded409complete was called before anything was uploaded to the URL.
content_precondition_failed412If-Match did not match. The body adds currentETag, or null when the resource does not exist or the key cannot see it.
content_asset_mismatch422The uploaded file differs from the declared size or type. It was deleted; ask for a new upload URL.
content_api_key_active409DELETE on an API key that still works (admin session). Revoke it first.
content_api_key_not_active409PATCH on an API key that is revoked or expired (admin session). Create a new key.
content_rate_limit_exceeded429The key used its per-minute budget, or the address failed authentication too often. See Limits.
content_asset_daily_limit_reached429The organization reached its upload limit for the last 24 hours. The body adds uploadLimit, byteLimit, uploadsUsed, bytesUsed, and retryAfterSeconds.
content_sandbox_spend_blocked500Something 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_error500Unexpected 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:

JSON
{
  "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:

WorkWhere the failure arrives
Expansion jobstatus: "failed" on the job, with error and each outputs[].error, and a content.failed webhook after the final attempt.
Test, module, or course generation runstatus: "failed" and error on GET .../runs/{runId}, and a *.generation.failed webhook.
Streamed game or simulation stepAn error event on the stream, after a 200. See Generation.
Async game or simulation stepA *.failed webhook, and last on GET .../run, including runs whose server stopped.
Video renderrenderStatus: "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:

FieldNotes
Request-IdFrom the response header or error.requestId. With it, support finds the request directly.
keyPrefixFrom the key list or Settings.
Route and methodFor example POST /v1/content/integrations/expansions.
Time, with time zone
Idempotency-KeyIf you sent one.
Response status and bodyThe whole error object.
Job id, or game or simulation id and contentVersionFor expansion, build, and restore issues.
Webhook delivery idFor delivery issues.

Was this page helpful?