Every number on this page is enforced by the API. Where a value has no enforced limit, the table says so; the request body cap still applies to it.
Request size
| Limit | Value | What happens past it |
|---|---|---|
JSON request body on /v1/content/** | 100 KB | 413 content_payload_too_large. |
| Malformed JSON | 400 content_invalid_request. | |
| File uploaded to an upload URL | Per purpose, 20 MB to 500 MB | The file does not pass through the Content API, so the 100 KB limit does not apply. See Asset Uploads. |
Field lengths
| Field | Limit |
|---|---|
Idempotency-Key on resource creates, actions, async generation, renders, and expansion jobs | 255 characters. Longer returns 400. |
X-Request-Id or Request-Id you send | 1 to 128 characters of letters, digits, ., _, and -. Anything else is replaced with a generated UUID. |
List search | 200 characters. Longer returns 422. |
API key name | 1 to 120 characters. |
API key rateLimitPerMinute | 1 to 600. Default 60. |
API key monthlyCreditLimit | A whole number from 1 to 10,000,000, or null for no limit. |
API key rotation gracePeriodHours | 0 to 168. |
Webhook secret rotation gracePeriodHours | 0 to 168. |
Webhook url | 2,048 characters, https, resolving to a public address. |
Webhook secret | 16 to 256 characters. |
Course chapter title | 1 to 255 characters. |
Course lesson title | 1 to 255 characters. |
Course lesson description | 2,000 characters. |
Course lesson lecture, content | 100,000 characters. lecture cannot be empty. |
Game or simulation revision feedback | 1 to 2,000 characters. The history note keeps the first 200. |
| Titles, descriptions, and bodies of modules, courses, videos, scenes, slides, tests, games, and simulations | No enforced limit beyond the 100 KB body. |
Game and simulation metadata, brief, referenceMarkdown | No enforced limit beyond the 100 KB body. |
Webhook delivery log eventType filter | 64 characters. |
Test generation topic, instructions | topic 1 to 4,000 characters; instructions up to 4,000. |
Module generation topic | 1 to 4,000 characters. |
Slide deck generation topic, instructions | topic 1 to 4,000 characters; instructions up to 4,000. |
Module generation referenceText | 100,000 characters. |
Course generation prompt, title, description | prompt 1 to 4,000 characters; title up to 255; description up to 4,000. |
Generation language | 35 characters. |
Upload filename | 255 characters. |
Learner list q | 100 characters. |
Learner invitation name | 100 characters. |
Learner invitation locale | 35 characters, a BCP 47 tag. |
cursor on cursor-paged lists | 200 characters. |
Ids per request
| Request | Limit |
|---|---|
PATCH .../courses/{courseId}/chapters/reorder chapterIds | 1 to 500, unique, and exactly the course's chapter ids. |
PATCH .../chapters/{chapterId}/lessons/reorder lessonIds | 1 to 500, unique, and exactly the chapter's lesson ids. |
PATCH .../videos/{videoId}/scenes/reorder sceneIds | At least 1. No enforced maximum. |
Key classroomIds | No enforced maximum. An empty list returns 400. |
Key scopes | Any of the 6 scopes. An empty list returns 400. |
Test generation itemCount | 1 to 30. itemTypes must have exactly itemCount entries. |
Course generation lessonCount | 1 to 30. Default 12. |
Slide deck generation slideCount | 5 to 20. Default 10. |
Learner invitation courseIds, testIds | Up to 100 each, unique, all in the classroom. |
Course enrollment learnerIds | 1 to 100, unique, all in the classroom. |
List pages
Resource lists (modules, courses, videos, slides, tests, games, and simulations) return the { "data", "meta" } envelope described in List responses, and all take the same query parameters:
| Parameter | Default | Behavior |
|---|---|---|
page | 1 | Starts at 1. Below 1 or not an integer returns 422. |
limit | 20 | Items per page. A value above 100 is clamped to 100, not refused; meta.take reports the size used. Below 1 or not an integer returns 422. |
take | Deprecated alias of limit, with the same default and clamp. limit wins when both are sent. See Current deprecations. | |
field | createdAt | The sort column, from the list's allowed values below. Anything else returns 422 with error.allowedValues. |
order | DESC | ASC or DESC. Ties are broken on id in the same direction, so a page never skips or repeats a row. |
search | Case-insensitive substring of the title (name on tests). % and _ match themselves. Up to 200 characters. | |
updatedSince | An ISO 8601 date-time, such as 2026-09-30T00:00:00Z or one with an offset. Only rows with updatedAt at or after it are listed. Not ISO 8601 returns 422. |
| List | field values | Other filters |
|---|---|---|
| Courses | createdAt, updatedAt, title, slug | isIncludeStats |
| Tests | createdAt, updatedAt, name, slug, and title (same as name) | isIncludeStats |
| Modules, slides, videos | createdAt, updatedAt, title | |
| Games | title, createdAt, updatedAt | |
| Simulations | title, createdAt, updatedAt, subject | subject |
Deleted rows are not listed. To learn about deletions, use the resource.deleted webhook.
The webhook delivery log pages differently: limit 1 to 100 (default 20), always newest first, with before for the next page and status and eventType filters. See Delivery log.
The credit history, audit log, generation runs, learners, enrollments, and test results page with a cursor instead: limit (default 20, values above 100 clamped to 100) and cursor, newest first, with { "data", "meta": { "limit", "hasNextPage", "nextCursor" } }. See Paging.
These routes return a plain array with no paging: GET /v1/content/classrooms, GET /v1/content/organizations, the API key list, and the webhook list. A course's GET .../chapters returns the whole curriculum with no paging, as { "data", "unassignedLessons" }.
Source JSON expansion
| Limit | Value |
|---|---|
| Levels per job | 1. The first level that has at least one lesson is expanded; other levels in the payload are ignored. See Source JSON Format. |
| Lessons per level | At least 1. No enforced maximum beyond the 100 KB body. |
| Lesson fields read per lesson | id, title or name, type, plus the first 6 other fields. Later fields are stored with the job but not used for generation. |
requestedOutputs | Up to 3 distinct values. Duplicates collapse, and outputs always run in the order interactive_module, summary_video, expanded_quiz. |
| Video and quiz outputs | Created empty: the summary_video video has no scenes and the expanded_quiz test has no items. Add them with the resource routes. |
| Internal attempts per job | 3. A failed attempt is retried after 5 seconds, then 25 seconds, before the job reports failed. |
Upload limits
| Limit | Value |
|---|---|
| Upload URLs per organization | 1,000 in any rolling 24 hours, across all keys. |
| Declared upload bytes per organization | 20 GB (21,474,836,480 bytes) in any rolling 24 hours. |
| Upload URLs and bytes with test keys | 50 uploads and 500 MB (524,288,000 bytes) in any rolling 24 hours, per organization, separate from the live allowance. |
| Upload URL lifetime | 15 minutes. |
| Size per file | 20 MB to 500 MB by purpose. See Purposes and limits. |
Past a daily limit, asking for an upload URL answers 429 content_asset_daily_limit_reached with Retry-After.
Webhook delivery
| Item | Value |
|---|---|
| Delivery after an event | Usually within about a second. |
| Order per endpoint | One delivery at a time, oldest first. |
| Pause after a failed attempt | About 30 seconds for that endpoint. |
| Automatic turn-off | At least 20 failed attempts in a row and at least 3 days failing, both together. |
replayFailedSince | At most 7 days ago; at most 10,000 events per request. |
See Endpoint health.
Rate limits
Each key has its own budget, rateLimitPerMinute (1 to 600, default 60), counted over a 60-second window. The budget is shared across every route the key calls, and a busy key never uses up another key's budget. Requests refused with 401, or with 403 for a missing scope or classroom, are not counted against the key.
Every counted response carries:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | The key's rateLimitPerMinute. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Seconds until the current window resets. |
Retry-After | Only on 429. Seconds to wait before the next request. |
A request over the limit returns 429:
{
"error": {
"code": "content_rate_limit_exceeded",
"message": "Rate limit exceeded. Retry after 12 seconds",
"retryAfterSeconds": 12,
"status": 429
}
}Wait for Retry-After, then send the same request again with the same Idempotency-Key:
// Node.js 18+
async function fetchWithRateLimit(url, init, maxAttempts = 5) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(url, init)
if (response.status !== 429 || attempt === maxAttempts) {
return response
}
const waitSeconds = Number(response.headers.get('retry-after') ?? '1')
await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1000))
}
}# Python 3 with requests
import time
import requests
def request_with_rate_limit(method, url, max_attempts=5, **kwargs):
for attempt in range(1, max_attempts + 1):
response = requests.request(method, url, timeout=30, **kwargs)
if response.status_code != 429 or attempt == max_attempts:
return response
time.sleep(int(response.headers.get("Retry-After", "1")))To stay under the limit in a batch, read X-RateLimit-Remaining and pause until X-RateLimit-Reset when it reaches 0. Browser clients can read these headers, and Retry-After: the Content API lists them in Access-Control-Expose-Headers. A complete wrapper that also handles 5xx, 402, and 409 is in Examples.
Failed authentication limit
Separate from the per-key budget, each client address may fail authentication (401) up to 300 times in a one-minute window. Once it reaches that, every request from the address to a tf_content_ route answers 429 content_rate_limit_exceeded, with Retry-After and error.retryAfterSeconds, until the window ends. The check runs before the key is looked up, so it applies even to requests with a valid key from that address.
Only 401s count, so valid traffic from one address is never limited by this; the per-key limits above apply to it instead. These 429s carry no X-RateLimit-* headers. If you see one, stop retrying with the bad key: fix the key, wait Retry-After, then resume.
Time limits and retention
| Item | Value |
|---|---|
| Idempotency replay window | 24 hours after the first request. Applies to expansion jobs too, except jobs created before 2026-09-30, whose keys have no expiry. |
| Idempotency takeover | A request with the same key still marked in progress after 5 minutes can be taken over by a retry. |
| Game or simulation build or revision | 30 minutes at most. A run that reaches it fails and charges nothing. |
| Test generation run | 20 minutes. |
| Module generation run | 30 minutes. |
| Course generation run | 15 minutes plus 8 minutes per lesson. |
| Slide deck generation run | 10 minutes plus 8 minutes per batch of four pages. |
| Attempts per generation step | 3, counting attempts cut short by a server restart. |
| Expansion job attempts | 3, 5 and then 25 seconds apart. content.failed is sent after the last. |
Finished run on GET .../run | Readable in last for 1 hour. |
Signed videoUrl from render status | 6 hours. Read the render status again for a new link. |
Signed videoUrl in the video.render.completed webhook | 6 hours from each delivery attempt. Every attempt and redelivery signs a new link. |
| Webhook receiver response | 10 seconds per attempt. |
| Webhook attempts | 9 over about 23 hours. |
| Webhook redirects followed | 3 hops, 307 and 308 only. |
| Webhook delivery log | 30 days. |
| Audit log | 90 days. |
| Upload records | 90 days. Uploaded files are not deleted with them. |
Pending upload settled as unconfirmed or abandoned | After 1 hour without complete. |
| Learner invitation | Expires after 24 hours. |
| Key monthly credit limit | Resets at 00:00 UTC on the 1st of each month. |
| API key expiry warning | Sent once, 7 days or less before expiresAt. See Expiry warnings. |
| Failed authentication window | 60 seconds, 300 401s per address. |
No enforced limit
These have no limit in the API today. If one is added, it is announced in the Changelog first, as described in Versioning and Deprecation.
- Webhook endpoints per organization.
- Webhook payload size. Payloads are small JSON objects; they do not carry built HTML or media.
- Content API keys per organization.
- Classrooms per key allowlist.
- Resources per classroom.
- Concurrent expansion jobs per key, beyond the rate limit.