Every Content API request sends a key as a bearer token:
Authorization: Bearer tf_content_...Keys start with tf_content_, belong to one organization, and work only on /v1/content/**. Test keys start with tf_content_test_ and work only in the organization's sandbox classroom, without spending credits. Create them in Settings with Test as the Mode, or with the API and "mode": "test"; see Test Mode. A bearer token in any other format is rejected with 401. Content API keys are separate from Agent Platform credentials; never send a platform key to /v1/content.
Create a key in Settings
Organization admins manage keys in TutorFlow under Settings > Content API (/dashboard/settings/content-api). There you can:
- Create a live or test key (the Mode choice, Live by default) with a name, scopes, an optional classroom allowlist, an optional expiry, a rate limit, and an optional monthly AI Credit limit.
- Copy the full key once, right after creating it.
- Edit a key's name, scopes, classrooms, rate limit, expiry, or monthly credit limit without changing its secret, and see what each key spent this month.
- Rotate a key, with a grace period during which the old key keeps working.
- Revoke a key, and delete a revoked or expired key from the list.
- See which keys hold full access and which were issued by an admin who has since left. See Key status notices.
- Manage webhooks: create live or test endpoints, test, read the delivery log, send deliveries again, and turn an endpoint back on after TutorFlow turned it off.
- Read the credit history and audit log.
When a request in Settings fails, the error shows its Request ID. Quote it when you contact TutorFlow support.
This is the default path. Use the API below only when key creation has to be automated.
Scopes
Give each key only the scopes it needs. A read-only reporting job, for example, needs only content:read.
| Scope | Allows |
|---|---|
content:read | Every GET route except the learner routes: classrooms, resources, jobs, generation runs, render status, credits, credit history, and the audit log. |
content:write | Creating, updating, and deleting resources, including course chapters and lessons, video scenes, module copy, and version restores. |
content:generate | Generation work: expansions (free), test, module, course, and slide deck generation, game and simulation brief, build, and revise, video narration, and video render and render cancel (these spend AI Credits). |
webhooks:manage | Managing the organization's webhooks under /v1/content/webhooks. Not available to a key with a classroom allowlist. |
learners:read | Learner data: the learner routes that read learners, progress, enrollments, and test results, the learner names and ids in course stats, and subscribing to learner.* webhooks with a key. Opt-in; see Learner data. |
learners:write | Inviting, enrolling, unenrolling, and removing learners with the learner routes. Opt-in. |
A route that does not name a scope uses the default for its method: GET needs content:read, every other method needs content:write. The scope of every route is in the API Reference.
A request with a key that lacks the scope fails with 403:
{
"error": {
"code": "content_insufficient_scope",
"message": "This API key lacks the content:generate scope",
"requiredScope": "content:generate",
"status": 403
}
}Keys created before scopes existed have no scope list (scopes: null) and hold every scope except the two opt-in learner scopes, learners:read and learners:write, so older integrations keep working without reaching learner data. The key list marks them with isLegacyFullAccess: true.
Learner data
learners:read and learners:write are the only scopes that reach data about individual learners. Both are opt-in: a key holds them only when an admin names them, even a key with no scope list. The learner routes need them, and so does subscribing a webhook endpoint to learner.* events with a key.
learners:read also covers learner names in course stats, GET /v1/content/classrooms/{classroomId}/courses/{courseId}?isIncludeStats=true:
- With
learners:read,statsincludestopLearners(userId,username,fullName,doneCount) andmostBehindLearner(username,fullName,doneCount). - Without it, those two keys are left out of
stats. Every aggregate stays:totalLearners,totalLearningTime,averageLearnerLearningTime,totalCompletedLessonCount,averageCompletedLessonCount,totalDoneLessonCount,behindLearnerCount,dailyLearningTime, and the lesson stats. The request does not fail with403, so a totals dashboard keeps working on a key without the scope. - Keys with no scope list (
scopes: null) keep receiving these two stats fields, as before, for compatibility. That exception covers course stats only; the learner routes still need the scopes.
Test stats (isIncludeStats=true on tests) hold only aggregate counts and need no extra scope. Grant the learner scopes only to an integration that has to show or manage learners by name.
Classroom allowlist
A key created with classroomIds reaches only those classrooms. GET /v1/content/classrooms lists only them, and a request whose URL names another classroom fails with 403 content_classroom_not_allowed before the route runs. Expansion jobs in other classrooms return 404. Keys without an allowlist use every classroom in the organization.
A classroom-limited key cannot manage webhooks, because webhooks carry events from every classroom. Creating a key with both classroomIds and webhooks:manage fails with 400, and a classroom-limited key created without scopes gets 403 content_insufficient_scope on the webhook routes.
Create a key with the API
Key management routes use a signed-in TutorFlow admin session, not a tf_content_ key; a key can never create, change, rotate, or revoke keys. Use this path for automation, for example to provision a key per environment. The key routes answer errors in the Content API envelope, with error.requestId; the organization list (GET /v1/content/organizations) still answers in TutorFlow's standard web shape, { "statusCode", "message", "error" }. Id path parameters (organizationId, keyId) must be UUIDs, otherwise 422 content_invalid_request.
1. Sign in. For an account that signs in with email and password, create a cookie jar. The session cookie is named jwt. Accounts that use SSO or Google sign-in should create keys in Settings instead.
export TUTORFLOW_API_BASE_URL="https://api.tutorflow.io"
curl -sS -c tutorflow-admin.cookies -X POST "$TUTORFLOW_API_BASE_URL/auth/login" \
-H "Content-Type: application/json" \
-H "Referer: https://tutorflow.io/sign-in" \
-d '{ "email": "admin@example.com", "password": "your-password", "timezone": "UTC" }'2. Find the organization. The list holds the organizations your account can manage keys for. An empty list means the account is not an admin of one.
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/organizations" -b tutorflow-admin.cookies[
{
"id": "00000000-0000-4000-8000-000000000001",
"name": "Customer Academy",
"slug": "customer-academy",
"role": "ADMIN"
}
]export ORG_ID="00000000-0000-4000-8000-000000000001" # the id you picked3. Create the key.
KEY_JSON="$(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-production",
"rateLimitPerMinute": 60,
"scopes": ["content:read", "content:write"],
"classroomIds": ["00000000-0000-4000-8000-000000000010"],
"expiresAt": "2027-09-30T00:00:00.000Z",
"monthlyCreditLimit": 500
}')"
export TUTORFLOW_CONTENT_API_KEY="$(printf '%s' "$KEY_JSON" | jq -r '.apiKey')"
export KEY_ID="$(printf '%s' "$KEY_JSON" | jq -r '.keyId')"Response 201:
{
"apiKey": "tf_content_...",
"keyId": "00000000-0000-4000-8000-000000000020",
"keyPrefix": "tf_content_abc123def456",
"name": "cms-sync-production",
"rateLimitPerMinute": 60,
"scopes": ["content:read", "content:write"],
"classroomIds": ["00000000-0000-4000-8000-000000000010"],
"expiresAt": "2027-09-30T00:00:00.000Z",
"monthlyCreditLimit": 500
}apiKey is returned only here. TutorFlow stores only a hash of it.
| Field | Required | When set | When omitted |
|---|---|---|---|
name | Yes | 1 to 120 characters. | |
mode | No | live or test. Fixed for the life of the key; rotation keeps it. See Test Mode. | live |
rateLimitPerMinute | No | 1 to 600. | 60 |
scopes | No | Any of content:read, content:write, content:generate, webhooks:manage, learners:read, learners:write. An empty list returns 400. | Every scope except learners:read and learners:write. |
classroomIds | No | Each must belong to the organization, otherwise 400. An empty list returns 400. A test key cannot take it (400). | Every classroom. |
expiresAt | No | A future ISO 8601 time. A past time returns 400. | Never expires. |
monthlyCreditLimit | No | A whole number of AI Credits from 1 to 10,000,000, or null. Anything else returns 422. See Monthly credit limit. | No limit. |
null in scopes or classroomIds in a response means "all". To leave one unlimited, omit it; do not send an empty list.
List keys
curl -sS "$TUTORFLOW_API_BASE_URL/v1/content/organizations/$ORG_ID/api-keys" -b tutorflow-admin.cookies[
{
"id": "00000000-0000-4000-8000-000000000020",
"name": "cms-sync-production",
"keyPrefix": "tf_content_abc123def456",
"status": "ACTIVE",
"organizationId": "00000000-0000-4000-8000-000000000001",
"rateLimitPerMinute": 60,
"lastUsedAt": "2026-09-30T09:00:00.000Z",
"expiresAt": "2027-09-30T00:00:00.000Z",
"scopes": ["content:read", "content:write"],
"classroomIds": ["00000000-0000-4000-8000-000000000010"],
"isLegacyFullAccess": false,
"issuerStatus": "active",
"monthlyCreditLimit": 500,
"spentThisMonth": 132,
"mode": "live",
"sandboxClassroomId": null,
"createdAt": "2026-09-01T09:00:00.000Z",
"updatedAt": "2026-09-01T09:00:00.000Z"
}
]The list never includes the full key or its hash. mode is live or test, and sandboxClassroomId is the sandbox classroom of a test key, null for a live key. spentThisMonth is what the key spent in the current UTC month, net of refunds, and 0 when it spent nothing.
Key status notices
| Field | Values | What to do |
|---|---|---|
isLegacyFullAccess | true when scopes is null: a key created before scopes existed, or created without scopes. It holds every scope, including scopes added later, except the opt-in learners:read and learners:write. | Narrow it to the scopes your integration needs with PATCH. Rotation keeps the scopes, so it does not change this. |
issuerStatus | active: the admin who issued the key is still an active admin. not_admin: they left or lost the admin role, so what the key creates is now owned by another admin (see Who owns what a key creates). unknown: the key predates issuer tracking. | For not_admin, rotate the key so an active admin issues it. |
Change a key
PATCH /v1/content/organizations/{organizationId}/api-keys/{keyId} changes a key in place. The secret does not change, so nothing has to be redeployed. Every field is optional; omitted fields keep their value. It needs the same access as creating a key: an active admin of the organization.
curl -sS -X PATCH "$TUTORFLOW_API_BASE_URL/v1/content/organizations/$ORG_ID/api-keys/$KEY_ID" \
-H "Content-Type: application/json" \
-b tutorflow-admin.cookies \
-d '{
"name": "cms-sync-production",
"scopes": ["content:read", "content:write", "learners:read"],
"rateLimitPerMinute": 120,
"expiresAt": "2027-01-31T00:00:00.000Z"
}'| Field | When set |
|---|---|
name | 1 to 120 characters. |
scopes | Replaces the scopes. A non-empty list of the six scopes. An empty list returns 400; null returns 422. A key cannot go back to legacy full access. To add a learner scope to a key with no scope list, send the full list you want, for example the four content scopes plus learners:read. |
classroomIds | Replaces the allowlist. A non-empty list of classroom ids in the organization, or null for every classroom. An empty list, a classroom from another organization, or any allowlist on a test key returns 400. mode cannot be changed. |
rateLimitPerMinute | An integer from 1 to 600. |
expiresAt | A future ISO 8601 time, or null for no expiry. A past time returns 400. Setting it re-arms the expiry warning. |
monthlyCreditLimit | A whole number from 1 to 10,000,000, or null to remove the limit. It applies at once to what the key already spent this month. |
webhooks:manage on a classroom-limited key returns 400. The check runs against the key as it will be stored, so adding classroomIds to a key that holds webhooks:manage, or the other way round, is refused too.
Response 200 with the key object, as in the list. Errors:
| Status | error.code | When |
|---|---|---|
400 | content_invalid_request | An empty list, a past expiresAt, a classroom outside the organization, or webhooks:manage with a classroom allowlist. |
404 | content_not_found | No such key in this organization. |
409 | content_api_key_not_active | The key is revoked or expired. Create a new key instead; a new expiry cannot bring one back. |
422 | content_invalid_request | A field has the wrong type or is out of range. error.details names it. |
Every change is recorded in the audit log with the admin who made it.
Rotate a key
Rotation issues a new key with the same name, rate limit, scopes, classroom allowlist, expiry, and monthly credit limit. Spend is counted per key, so the new key starts the month at 0. The new key is issued by the admin who rotates it, so resources it creates are owned by that admin (see Who owns what a key creates). Create a new key instead if the replacement needs a different policy.
gracePeriodHours (0 to 168) keeps the old key working for that many hours. Omitted or 0, the old key stops working at once.
ROTATE_JSON="$(curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/organizations/$ORG_ID/api-keys/$KEY_ID/rotate" \
-H "Content-Type: application/json" \
-b tutorflow-admin.cookies \
-d '{ "gracePeriodHours": 24 }')"Response 200:
{
"apiKey": "tf_content_...",
"keyId": "00000000-0000-4000-8000-000000000021",
"keyPrefix": "tf_content_def456abc789",
"name": "cms-sync-production",
"rateLimitPerMinute": 60,
"scopes": ["content:read", "content:write"],
"classroomIds": ["00000000-0000-4000-8000-000000000010"],
"expiresAt": "2027-09-30T00:00:00.000Z",
"monthlyCreditLimit": 500,
"previousKeyExpiresAt": "2026-10-01T09:00:00.000Z"
}previousKeyExpiresAt is when the old key stops working: the end of the grace period, the old key's own expiresAt if that comes first, or the rotation time when there is no grace period.
Planned rotation, no downtime:
- Rotate with a grace period long enough for a deploy, for example
"gracePeriodHours": 24. - Put the new
apiKeyin your secret manager and deploy. - Confirm the new key works with
GET /v1/content/credits. - Let the old key expire at
previousKeyExpiresAt.
Revoke a key
curl -sS -X POST "$TUTORFLOW_API_BASE_URL/v1/content/organizations/$ORG_ID/api-keys/$KEY_ID/revoke" \
-b tutorflow-admin.cookiesResponse 200 with no body. The key stops working at once.
Delete a key
A revoked or expired key stays in the list until you delete it. Deleting only removes it from the list; the key already no longer works. In Settings, a revoked or expired key has a Delete button.
curl -sS -X DELETE "$TUTORFLOW_API_BASE_URL/v1/content/organizations/$ORG_ID/api-keys/$KEY_ID" \
-b tutorflow-admin.cookiesResponse 200 with { "id", "deleted": true }. A key that still works answers 409 content_api_key_active: revoke it first. A deleted key is gone from GET .../api-keys, and changing it answers 404. Audit log entries made with it are kept.
Monthly credit limit
An admin can give each key a monthly AI Credit limit, monthlyCreditLimit, when creating or changing it, in Settings or with the API. A key without one spends from the organization's balance only, as before.
- The month is the UTC calendar month. It starts at 00:00 UTC on the 1st and the limit resets at 00:00 UTC on the 1st of the next month.
- What the key spent is its charges minus its refunds in that month, never below 0. A refund for a charge from last month lowers this month's spend.
- An action is refused when what the key spent plus the action's cost would go over the limit. Reaching the limit exactly is allowed.
- Only charges made with the key count. What people do in the TutorFlow app is never attributed to a key.
- The organization's balance still applies. A key under its limit can still get
402content_payment_requiredwhen the organization runs out. - Changing the limit applies at once to what the key already spent this month.
A refused action answers 402 and costs nothing:
{
"error": {
"code": "content_key_budget_exceeded",
"message": "This API key has reached its monthly AI Credit limit. Raise the limit in TutorFlow Settings or wait until it resets",
"limit": 500,
"spent": 497,
"requested": 8,
"resetsAt": "2026-11-01T00:00:00.000Z",
"status": 402,
"requestId": "3f0c2a9e-6b1d-4c8e-9f2a-7d5b1e0c4a11"
}
}Do not retry it until resetsAt, or until an admin raises the limit. Read the key's spend in spentThisMonth on the key list, or in the credit history.
The limit can be passed by the work already running. The limit is checked when an action starts, and nothing is reserved. Several actions of the same key checked at the same moment can all pass, and an action with several steps, such as a video with narration and a render or a course with many lessons, checks each step as it starts and finishes a step it started. So a key can end a month above its limit by at most the cost of what it had running at once. If you need a hard ceiling, set the limit a little below it.
Expiry warnings
Once a day TutorFlow looks for active keys that expire within 7 days. For each one it sends, once per expiry:
- An email to the admin who issued the key, or to every active admin of the organization when the issuer is no longer one. It is written in the admin's TutorFlow language (English, Korean, Japanese, Simplified Chinese, Traditional Chinese, Spanish, French, German, Vietnamese, Mongolian, or Brazilian Portuguese; English otherwise) and links to Settings > Content API.
- An
api_key.expiringwebhook to every endpoint that subscribes to it, so an integration can raise its own alert. See Key events.
Changing expiresAt with PATCH re-arms the warning for the new date. The old key that a rotation with a grace period retires is not warned about.
If a key leaks
- Rotate it with
"gracePeriodHours": 0, in Settings or with the API. The leaked key stops working at once and you get a replacement with the same policy. - Deploy the new key to your secret manager and services.
- Verify with
GET /v1/content/credits, then check that your integration's requests succeed. - Review the leaked key's activity with TutorFlow support: send the key prefix and the time window. Every change made with a key is recorded with its key id.
Requests fail between steps 1 and 2. That is the intended trade: a leaked key must not keep working.
Rate limits
Each key has its own rateLimitPerMinute, counted over a 60-second window and shared across every route the key calls. Responses carry X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset, and a request over the limit returns 429 with Retry-After. See Limits.
Who owns what a key creates
Resources created with a key appear in TutorFlow as owned by an organization admin: the admin who created the key while they remain an active admin, otherwise the organization's longest-standing active admin. If the organization has no active admin, requests that create or change resources fail with 403 content_forbidden.
Every request that changes something (every method other than GET) is recorded in an audit log with the key id, route, resource, status code, Idempotency-Key, and Request-Id. Key and webhook changes an admin makes in Settings or with an admin session are recorded with the admin's user id. Read it with GET /v1/content/audit-log; see Credit History and Audit Log.
Storage rules
- Store the full key in a secret manager. Never commit it or log it.
- Use a separate key per environment, so test traffic and production traffic have separate rate limits and can be rotated independently. For development and CI, use a test key, which cannot reach real classrooms or spend credits.
- Share only the
keyPrefixwith TutorFlow support. - Set
expiresAton keys issued to vendors or for a limited project, and subscribe toapi_key.expiringso the expiry does not surprise you. - Rotate after staff or vendor changes, and whenever
issuerStatusisnot_admin.