Resources
Testing an Integration

Testing an Integration

The behaviors to verify before a Content API integration goes to production, how to troubleshoot failures, and what to hand off.

On this page

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

CheckExpected
GET /v1/content/classrooms with a test keyOnly the sandbox, with isSandbox: true.
A real classroom id with a test key404 content_not_found.
A generate request with a test key202 with the live estimatedCredits; the run completes within seconds with creditsCharged: 0.
GET /v1/content/credits before and afterThe same balance.
A webhook from the sandboxArrives at your test endpoint only, with livemode: false.
A learner route with a test key403 content_sandbox_unsupported.

Expected behaviors

Authentication and scopes

CheckExpected
GET /v1/content/credits with the key200 with the balance.
The same request with no Authorization header, or a non-tf_content_ token401 content_invalid_api_key.
A route that needs a scope the key lacks403 content_insufficient_scope with error.requiredScope.
A classroom outside the key's allowlist403 content_classroom_not_allowed.
Responses to key requestsX-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers, and a Request-Id header.
Any errorerror.requestId equal to the Request-Id header.
A request with X-Request-Id: my-trace-123Request-Id: my-trace-123 on the response.
GET .../classrooms/abc/courses (not a UUID)422 content_invalid_request naming classroomId.

Resources

CheckExpected
POST a resource with a new Idempotency-Key201, one resource created.
The same POST with the same key and bodyThe same response with Idempotent-Replayed: true; no second resource.
The same key with a different body409 content_conflict.
GET the returned id200 with the resource.
PATCH the id200 with the updated resource. Modules, slides, and tests also carry the deprecated success: true.
PATCH a course with only titlevisibility and the other fields are unchanged.
DELETE the id200 with { "id", "deleted": true, "success": true }. A later GET returns 404.
Module copy or scene create retried with the same keyThe first response; no duplicate lesson or scene.
Module copy or version restore200.
Version restore retried with the same keyThe first response; buildHistory gains one entry, not two.
A body field with the wrong type422 with error.details.
GET /games?field=unknown422 content_invalid_request with error.allowedValues.
A list with no parametersUp to 20 items, newest createdAt first.
A list with limit=500100 items; meta.take is 100.
A list with updatedSince set to a minute ago, after changing one resourceOnly that resource.
A list responseDeprecation, 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 ETag412 content_precondition_failed with error.currentETag; nothing changed.
A thumbnail or pdfKey from another classroom400 content_invalid_request.
A test PATCH with an items[].id from another test404 content_not_found; nothing saved.

Uploads (only if the integration uploads files)

CheckExpected
POST /assets with a valid purpose, type, and size201 with uploadUrl and assetKey.
PUT the file with the returned headers200 from storage. A different Content-Type or size gets 403.
POST /assets/{assetId}/complete200 with status: "ready".
The key in a field of its purposeSaved.
The key in a field of another purpose400 naming the purpose the field takes.

Expansion

CheckExpected
A payload with one level and at least one lesson202, status: "queued", one output row per requested output.
A payload with no lessons400 content_invalid_request.
The same Idempotency-Key and body again202 with the same job in its current state, and Idempotent-Replayed: true.
The same Idempotency-Key with a different body409 content_conflict.
Pollingqueued, then processing, then completed or failed.
ResultOne entry per requested output, each with resourceType and resourceId. creditAmount is 0: expansions are free.

Generation and rendering (only if the integration uses them)

CheckExpected
Build before a brief exists400.
Async brief or build202 with runId; GET .../run shows it in active, then in last.
Async build retried with the same Idempotency-KeyThe same 202 and runId, with Idempotent-Replayed: true; charged once.
A second build while one runs409 content_conflict.
Render retried with the same keyThe first 202; charged once.
Render while rendering409 content_video_render_in_progress.
POST /tests/generate with a small itemCount202 with runId and estimatedCredits; GET /runs/{runId} ends completed with resourceId.
The same generate request retried with the same keyThe same 202 and runId, with Idempotent-Replayed: true.
A generate request whose estimatedCredits is over the key's monthly limit402 content_key_budget_exceeded with limit, spent, requested, and resetsAt.

Learners (only if the integration manages learners)

CheckExpected
GET /learners with a key without learners:read403 content_insufficient_scope with requiredScope: "learners:read".
POST /learners/invitations for an existing member201 with status: "added" and a learnerId.
POST /courses/{courseId}/enrollments for a learner outside the classroom422 listing the learner.
The same enrollment twiceThe second call changes nothing.
A learner responseCache-Control: no-store.

Webhooks

CheckExpected
POST .../webhooks/{webhookId}/testThe receiver answers 2xx; the delivery record shows it.
A body changed by one byteThe receiver rejects the signature.
A delivery replayed with an old timestampThe receiver rejects it.
The same X-Content-Integration-Event-Id twiceProcessed once.
Secret rotation with a grace periodThe receiver keeps verifying throughout.
The endpoint object after a failed deliveryconsecutiveFailures above 0 and failingSince set.
PATCH a DISABLED endpoint with status: "ACTIVE" and replayFailedSince200 with replayedDeliveries; the failed events arrive again with their event ids.

Troubleshooting

SymptomLikely causeFix
401Missing key, wrong format, revoked, or expired.Send Authorization: Bearer tf_content_... with a current key.
429 right after many 401sThe address hit the failed authentication limit.Fix the key, then wait Retry-After seconds.
422 naming an idThe id in the path is not a UUID.Use the id exactly as the API returned it.
403 content_insufficient_scopeThe 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_allowedThe classroom is outside the key's allowlist.Use a classroom from GET /v1/content/classrooms.
400 on expansionNo level with lessons.See Source JSON Format.
409 on expansion after changing the sourceThe expansion Idempotency-Key was reused with a different body.Change the key when the source changes.
409 after changing a create bodyThe key was used with a different body.Use a new key for the new version.
413The body is over 100 KB.Send less per request, for example one level per expansion job.
429The key's budget for this minute is spent.Wait Retry-After seconds. Lower concurrency.
A module or video is not ready to publishExpansion outputs are editable drafts.Review them in TutorFlow before publishing.
Build response is text/event-streamStreaming is the default for brief, build, and revise.Send Prefer: respond-async for server-to-server code.
A streamed build ends with no terminal eventThe 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 againIdempotency-Key is ignored in streaming mode.Use async mode, where the key replays the first 202.
Webhook signature never matchesThe 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 browserfetch adds Cache-Control: no-cache.Send Cache-Control: max-age=0 too. See fetch() needs Cache-Control: max-age=0.
412 on every updateThe If-Match tag is stale, or unquoted.Read the resource again and send its ETag exactly, quotes included.
Storage answers 403 to the upload PUTThe 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_exceededThe key reached its monthly credit limit.Wait for resetsAt, or ask an admin to raise the limit.
An endpoint stopped receiving events and shows DISABLEDTutorFlow 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:

  1. The key prefixes of the test and production keys, their scopes, classroom allowlists, and expiry.
  2. How the production key is stored and rotated, and the grace period you use.
  3. The Idempotency-Key pattern for each resource and action.
  4. Where TutorFlow ids, job ids, and result manifests are stored next to your own ids.
  5. The webhook URL, subscribed events, how the secret is stored, and how duplicates are skipped.
  6. Expected request volume per minute, compared with rateLimitPerMinute.
  7. The expected credit spend per month, from Pricing, and who approves it.
  8. Who reviews generated content before it reaches learners.

Was this page helpful?