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. The request is refused by the HTTP layer before it reaches the Content API, so the body may not carry the error.code envelope. |
| Malformed JSON | 400, also refused before the Content API, so it may not carry error.code. |
Field lengths
| Field | Limit |
|---|---|
Idempotency-Key on resource creates, actions, async generation, and renders | 255 characters. Longer returns 400. |
Idempotency-Key on expansion jobs | No enforced limit. |
API key name | 1 to 120 characters. |
API key rateLimitPerMinute | 1 to 600. Default 60. |
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. |
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 scopes | Any of the 4 scopes. An empty list returns 400. |
Key classroomIds | No enforced maximum. An empty list returns 400. |
List pages
Resource lists return the { "data", "meta" } envelope described in List responses. The page size parameter is not the same on every list:
| List | Page size parameter | Default | Maximum | Sort parameters | Search |
|---|---|---|---|---|---|
| Modules | limit | 10 | 5,000 | field (default title), order (default ASC) | search on title and description, case-insensitive |
| Courses | limit | 10 | 5,000 | field (default createdAt), order (default DESC) | search on title and slug, case-sensitive |
| Videos | take | 10 | 50 | None. Always newest first by createdAt. | None |
| Slides | limit | 10 | 5,000 | field (default title), order (default DESC) | search on title and description, case-insensitive |
| Tests | limit | 10 | 5,000 | field (default createdAt), order (default DESC) | search on name and slug, case-insensitive |
| Games | limit | 10 | 5,000 | field: title (default), createdAt, updatedAt. order (default DESC) | search on title and description, case-insensitive |
| Simulations | limit | 10 | 5,000 | field: title (default), createdAt, updatedAt, subject. order (default DESC) | search on title and description, case-insensitive, plus a subject filter |
| Webhook deliveries | limit | 20 | 100 | Always newest first. Page with before. | status, eventType filters |
Every list takes page, starting at 1. order is ASC or DESC. On games and simulations a field outside the listed values returns 422. On modules, courses, slides, and tests, use createdAt, updatedAt, or the title column (title, or name on tests); other values are not supported. A page size above the maximum returns 422.
These routes return a plain array with no paging: GET /v1/content/classrooms, GET /v1/content/organizations, the API key list, the webhook list, and a course's GET .../chapters.
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. |
| Generated quiz items | 4 per lesson, at least 10 and at most 30. |
| Generated video scenes | Lesson count plus 1, at least 3 and at most 8. |
| Generated video target length | 30 seconds per lesson, at least 45 and at most 180 seconds. |
| Internal attempts per job | 3. A failed attempt is retried after 5 seconds, then 25 seconds, before the job reports failed. |
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.
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. A complete wrapper that also handles 5xx, 402, and 409 is in Examples.
Time limits and retention
| Item | Value |
|---|---|
| Idempotency replay window | 24 hours after the first request finished. |
| 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. |
Finished run on GET .../run | Readable in last for 1 hour. |
Signed videoUrl from render status and the video.render.completed webhook | 6 hours. Read the render status again for 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 of key changes | 90 days. |
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.