Start with a test key (tf_content_test_...), created in Settings > Content API with Test as the Mode, or with the admin API and "mode": "test", and a test webhook endpoint created the same way. It works in a private sandbox classroom, answers generation, builds, narration, and renders with canned content, and never spends AI Credits, so you can run every check below as often as you like. Then repeat a few checks with a live key, separate from production, before going live; the learner checks need a live key. This page lists what to verify.
Test mode
| Check | Expected |
|---|---|
GET /v1/content/classrooms with a test key | Only the sandbox, with isSandbox: true. |
| A real classroom id with a test key | 404 content_not_found. |
| A generate request with a test key | 202 with the live estimatedCredits; the run completes within seconds with creditsCharged: 0. |
GET /v1/content/credits before and after | The same balance. |
| A webhook from the sandbox | Arrives at your test endpoint only, with livemode: false. |
| A learner route with a test key | 403 content_sandbox_unsupported. |
Expected behaviors
Authentication and scopes
| Check | Expected |
|---|---|
GET /v1/content/credits with the key | 200 with the balance. |
The same request with no Authorization header, or a non-tf_content_ token | 401 content_invalid_api_key. |
| A route that needs a scope the key lacks | 403 content_insufficient_scope with error.requiredScope. |
| A classroom outside the key's allowlist | 403 content_classroom_not_allowed. |
| Responses to key requests | X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers, and a Request-Id header. |
| Any error | error.requestId equal to the Request-Id header. |
A request with X-Request-Id: my-trace-123 | Request-Id: my-trace-123 on the response. |
GET .../classrooms/abc/courses (not a UUID) | 422 content_invalid_request naming classroomId. |
Resources
| Check | Expected |
|---|---|
POST a resource with a new Idempotency-Key | 201, one resource created. |
The same POST with the same key and body | The same response with Idempotent-Replayed: true; no second resource. |
| The same key with a different body | 409 content_conflict. |
GET the returned id | 200 with the resource. |
PATCH the id | 200 with the updated resource. Modules, slides, and tests also carry the deprecated success: true. |
PATCH a course with only title | visibility and the other fields are unchanged. |
DELETE the id | 200 with { "id", "deleted": true, "success": true }. A later GET returns 404. |
| Module copy or scene create retried with the same key | The first response; no duplicate lesson or scene. |
| Module copy or version restore | 200. |
| Version restore retried with the same key | The first response; buildHistory gains one entry, not two. |
| A body field with the wrong type | 422 with error.details. |
GET /games?field=unknown | 422 content_invalid_request with error.allowedValues. |
| A list with no parameters | Up to 20 items, newest createdAt first. |
A list with limit=500 | 100 items; meta.take is 100. |
A list with updatedSince set to a minute ago, after changing one resource | Only that resource. |
| A list response | Deprecation, Sunset, and Link headers, because it still carries items. |
GET a resource, then the same GET with If-None-Match set to its ETag (with curl, or with fetch and Cache-Control: max-age=0) | 304 with no body. |
PATCH with If-Match set to an old ETag | 412 content_precondition_failed with error.currentETag; nothing changed. |
A thumbnail or pdfKey from another classroom | 400 content_invalid_request. |
A test PATCH with an items[].id from another test | 404 content_not_found; nothing saved. |
Uploads (only if the integration uploads files)
| Check | Expected |
|---|---|
POST /assets with a valid purpose, type, and size | 201 with uploadUrl and assetKey. |
PUT the file with the returned headers | 200 from storage. A different Content-Type or size gets 403. |
POST /assets/{assetId}/complete | 200 with status: "ready". |
| The key in a field of its purpose | Saved. |
| The key in a field of another purpose | 400 naming the purpose the field takes. |
Expansion
| Check | Expected |
|---|---|
| A payload with one level and at least one lesson | 202, status: "queued", one output row per requested output. |
| A payload with no lessons | 400 content_invalid_request. |
The same Idempotency-Key and body again | 202 with the same job in its current state, and Idempotent-Replayed: true. |
The same Idempotency-Key with a different body | 409 content_conflict. |
| Polling | queued, then processing, then completed or failed. |
| Result | One entry per requested output, each with resourceType and resourceId. creditAmount is 0: expansions are free. |
Generation and rendering (only if the integration uses them)
| Check | Expected |
|---|---|
| Build before a brief exists | 400. |
| Async brief or build | 202 with runId; GET .../run shows it in active, then in last. |
Async build retried with the same Idempotency-Key | The same 202 and runId, with Idempotent-Replayed: true; charged once. |
| A second build while one runs | 409 content_conflict. |
| Render retried with the same key | The first 202; charged once. |
| Render while rendering | 409 content_video_render_in_progress. |
POST /tests/generate with a small itemCount | 202 with runId and estimatedCredits; GET /runs/{runId} ends completed with resourceId. |
| The same generate request retried with the same key | The same 202 and runId, with Idempotent-Replayed: true. |
A generate request whose estimatedCredits is over the key's monthly limit | 402 content_key_budget_exceeded with limit, spent, requested, and resetsAt. |
Learners (only if the integration manages learners)
| Check | Expected |
|---|---|
GET /learners with a key without learners:read | 403 content_insufficient_scope with requiredScope: "learners:read". |
POST /learners/invitations for an existing member | 201 with status: "added" and a learnerId. |
POST /courses/{courseId}/enrollments for a learner outside the classroom | 422 listing the learner. |
| The same enrollment twice | The second call changes nothing. |
| A learner response | Cache-Control: no-store. |
Webhooks
| Check | Expected |
|---|---|
POST .../webhooks/{webhookId}/test | The receiver answers 2xx; the delivery record shows it. |
| A body changed by one byte | The receiver rejects the signature. |
| A delivery replayed with an old timestamp | The receiver rejects it. |
The same X-Content-Integration-Event-Id twice | Processed once. |
| Secret rotation with a grace period | The receiver keeps verifying throughout. |
| The endpoint object after a failed delivery | consecutiveFailures above 0 and failingSince set. |
PATCH a DISABLED endpoint with status: "ACTIVE" and replayFailedSince | 200 with replayedDeliveries; the failed events arrive again with their event ids. |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 | Missing key, wrong format, revoked, or expired. | Send Authorization: Bearer tf_content_... with a current key. |
429 right after many 401s | The address hit the failed authentication limit. | Fix the key, then wait Retry-After seconds. |
422 naming an id | The id in the path is not a UUID. | Use the id exactly as the API returned it. |
403 content_insufficient_scope | The key lacks the scope, or is classroom-limited and called a webhook route. | Use a key with error.requiredScope. Webhook routes need a key without a classroom allowlist. |
403 content_classroom_not_allowed | The classroom is outside the key's allowlist. | Use a classroom from GET /v1/content/classrooms. |
400 on expansion | No level with lessons. | See Source JSON Format. |
409 on expansion after changing the source | The expansion Idempotency-Key was reused with a different body. | Change the key when the source changes. |
409 after changing a create body | The key was used with a different body. | Use a new key for the new version. |
413 | The body is over 100 KB. | Send less per request, for example one level per expansion job. |
429 | The key's budget for this minute is spent. | Wait Retry-After seconds. Lower concurrency. |
| A module or video is not ready to publish | Expansion outputs are editable drafts. | Review them in TutorFlow before publishing. |
Build response is text/event-stream | Streaming is the default for brief, build, and revise. | Send Prefer: respond-async for server-to-server code. |
| A streamed build ends with no terminal event | The client timed out and hung up, which aborts the build. | Use async mode, or a timeout in minutes. Nothing was charged. |
| A retried streamed build built again | Idempotency-Key is ignored in streaming mode. | Use async mode, where the key replays the first 202. |
| Webhook signature never matches | The body was parsed before verifying, or the wrong secret. | Verify the raw bytes; see Verify the signature. |
If-None-Match never gets 304 from Node.js or a browser | fetch adds Cache-Control: no-cache. | Send Cache-Control: max-age=0 too. See fetch() needs Cache-Control: max-age=0. |
412 on every update | The If-Match tag is stale, or unquoted. | Read the resource again and send its ETag exactly, quotes included. |
Storage answers 403 to the upload PUT | The Content-Type or size differs from the upload request, or the URL expired. | Send the returned headers and the declared bytes. Retry the upload request with the same Idempotency-Key for a new URL. |
402 content_key_budget_exceeded | The key reached its monthly credit limit. | Wait for resetsAt, or ask an admin to raise the limit. |
An endpoint stopped receiving events and shows DISABLED | TutorFlow turned it off after repeated failures. | Fix the receiver, then turn it back on with replayFailedSince. |
Anything else: see Errors, and send support the fields in Contacting support, starting with the Request-Id.
Handoff to production
Hand over, per integration:
- The key prefixes of the test and production keys, their scopes, classroom allowlists, and expiry.
- How the production key is stored and rotated, and the grace period you use.
- The
Idempotency-Keypattern for each resource and action. - Where TutorFlow ids, job ids, and result manifests are stored next to your own ids.
- The webhook URL, subscribed events, how the secret is stored, and how duplicates are skipped.
- Expected request volume per minute, compared with
rateLimitPerMinute. - The expected credit spend per month, from Pricing, and who approves it.
- Who reviews generated content before it reaches learners.