A test key lets you build an integration end to end without touching real classrooms or spending AI Credits. It works only in the organization's API sandbox, a private classroom that TutorFlow makes for it. Requests are validated exactly as in production, and the routes that would call a model or a render engine answer with canned content instead.
Start every new integration with a test key, then switch to a live key once the flow works.
Test keys
| Live key | Test key | |
|---|---|---|
| Starts with | tf_content_ | tf_content_test_ |
| Classrooms | Every classroom, or its allowlist | Only the organization's API sandbox |
| AI Credits | Spent at the editor's prices | Never spent |
| Webhooks | Sent to live endpoints, livemode: true | Sent to test endpoints only, livemode: false |
| Learner API | Available with the learner scopes | Not available |
Both authenticate the same way, Authorization: Bearer ..., and have the same scopes, rate limit, expiry, and audit trail.
Create a test key in Settings > Content API: choose Test under Mode when you create the key (the default is Live). Settings does not offer the learner scopes or a monthly credit limit for a test key, since neither applies in test mode. Add a test webhook endpoint the same way, with Test under Mode on Add webhook; learner events cannot be picked for it.
To automate it, create the key with an admin session and "mode": "test" (signing in is described in Create a key with the API):
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/organizations/$ORG_ID/api-keys" \
-H "Content-Type: application/json" \
-b tutorflow-admin.cookies \
-d '{ "name": "cms-sync-test", "mode": "test", "scopes": ["content:read", "content:write", "content:generate", "webhooks:manage"] }'modeislive(the default) ortest, and it is fixed:PATCHcannot change it, and rotating a test key gives a test key.- A test key cannot take
classroomIds(400on create and onPATCH); it is already limited to the sandbox. - Key objects carry
mode. The key list and thePATCHanswer also carrysandboxClassroomId, the sandbox's id for a test key andnullfor a live key.
See Keys and Authentication for the other fields.
The sandbox classroom
Each organization has one sandbox classroom, named "API sandbox", made when its first test key is created or first used.
- A test key works only there.
GET /v1/content/classroomslists only the sandbox, withisSandbox: true. Any other classroom id answers404content_not_found. - A live key never sees the sandbox: it is missing from the live key's classroom list, and its id answers
404. - An expansion job from a test key must name the sandbox as its
classroomId. - In the TutorFlow app, the sandbox is left out of the organization's classroom list and switcher, has no public pages, and does not count toward the plan's classroom or learner limits. Admins can open it by its id.
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/classrooms" \
-H "Authorization: Bearer $TUTORFLOW_CONTENT_TEST_KEY"[
{
"id": "00000000-0000-4000-8000-000000000099",
"name": "API sandbox",
"slug": "api-sandbox-1a2b3c4d",
"locale": "en",
"isSandbox": true
}
]What answers differently
Everything that does not call a model works exactly as with a live key: creating, reading, updating, and deleting resources, curriculum and scene routes, ETags, idempotency, expansions, uploads, and webhook management. Requests that a live key would get 400 or 422 for get the same answer.
The routes that would spend credits answer in the same shape, with canned content and no charge:
| Route | Test mode |
|---|---|
Game and simulation brief | A canned plan is saved, with the same brief-done event when streaming, or the same 202, run, and webhook in async mode. |
Game and simulation build, revise | A tracked run that saves a placeholder page as the next version (progress stage sandbox, then build-done), with the same webhooks. |
POST .../tests/generate | The same 202, with the estimatedCredits a live run would cost. One step (stage sandbox) saves a test of three fixed questions (true or false, multiple choice, fill in the blank), with the requested totalScore spread across them, and the run completes within seconds. |
POST .../modules/generate | As above, saving a markdown module whatever type was asked for. |
POST .../courses/generate | As above, saving one chapter of lessonCount lessons (3 without lessonCount). |
POST .../slides/generate | As above, saving a fixed three-page deck. |
POST .../videos/{videoId}/render | Finishes at once with a shared sample mp4. The 202 still says renderStatus: "RENDERING" and estimatedSeconds: 0; GET .../render then answers COMPLETED with a videoUrl, and video.render.completed goes to test endpoints. |
POST .../videos/{videoId}/narration, .../scenes/{sceneId}/narration | Every scene gets a shared sample narration clip. |
Every generation run reports creditsCharged: 0.
No credits, ever
Nothing a test key does spends or refunds AI Credits, and nothing it does appears in the credit history.
- Credit checks always pass: a test key never gets
402content_payment_requiredorcontent_key_budget_exceeded, even when the organization's balance is 0. GET /v1/content/creditsstill reports the organization's real balance. Test mode never changes it.- If anything in TutorFlow tried to charge during a test request, the request is refused with
500content_sandbox_spend_blockedrather than charged. You should never see it; if you do, send itsRequest-Idto TutorFlow support.
Because canned results cost nothing, estimatedCredits on a generate request is the best way to preview what the same request would cost with a live key.
Webhooks in test mode
Every webhook endpoint has a mode, and every payload carries livemode at the top level, next to event:
{
"resourceType": "course",
"resourceId": "00000000-0000-4000-8000-000000000201",
"classroomId": "00000000-0000-4000-8000-000000000099",
"runId": "00000000-0000-4000-8000-000000000b01",
"status": "completed",
"stage": "sandbox",
"progress": { "completed": 3, "total": 3, "unit": "lesson" },
"creditsCharged": 0,
"error": null,
"livemode": false,
"event": {
"id": "00000000-0000-4000-8000-000000000908",
"type": "course.generation.completed",
"createdAt": "2026-10-01T09:00:05.000Z"
}
}- Test endpoints get only sandbox events, and live endpoints never get them. This holds for every event family, including
api_key.expiring, which follows the mode of the expiring key. - An endpoint created with an API key takes the key's mode; naming the other
modeanswers400. An admin chooses withmodein the create body (defaultlive). - A key sees, changes, tests, and deletes only endpoints of its own mode; others answer
404. Admins see both. - A test ping carries the
livemodeof its endpoint. - A test endpoint cannot subscribe to
learner.*events:403content_sandbox_unsupported.
Check livemode in your receiver before acting on an event, so a test event can never change production data.
Learner API
The learner routes need a live key. A test key gets 403 on every one:
{
"error": {
"code": "content_sandbox_unsupported",
"message": "The learner API is not available in test mode; use a live key",
"status": 403,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}An empty answer would look like "no learners yet" and teach an integration nothing about real enrollment data, so the routes refuse instead. No learner.* event is ever sent for the sandbox.
Uploads
Test keys upload into the sandbox classroom, with their own allowance: 50 uploads and 500 MB (524,288,000 bytes) in any rolling 24 hours per organization. Sandbox uploads never count toward the live allowance of 1,000 uploads and 20 GB. Past the sandbox allowance, the answer is the usual 429 content_asset_daily_limit_reached, with the sandbox numbers in uploadLimit and byteLimit. See Asset Uploads.
Credit history and audit log
- A test key reads only its own rows on
GET /v1/content/credits/historyandGET /v1/content/audit-log, even with every scope. Its credit history is always empty. - Sandbox changes are recorded in the audit log under the test key, where admins and full-access live keys can read them.
See Credit History and Audit Log.
Going live
- Build and test the flow with the test key in the sandbox, including your webhook receiver on a test endpoint.
- Create a live key with the scopes and classrooms the integration needs, and a live webhook endpoint.
- Replace the sandbox classroom id with the real classroom from
GET /v1/content/classrooms; ids from the sandbox do not exist for the live key. - Run one small real request and check what it charged in the credit history before larger batches.