Creating Courses via API
End-to-end guide to programmatic course creation and updates
Create and update entire courses — course groups, sessions, sections, lessons, and topics — in a single API call, then publish, archive, and automate them at scale. This tutorial is a task-oriented walkthrough; for the exhaustive field-by-field contract see the Create courses and Update courses reference pages and Rate limits.
What you can and can't create over REST. You can create and update the full learning-content hierarchy (course groups, courses/sessions, sections, lessons, topics) for five course kinds, publish/unpublish, and archive. There is no REST endpoint to hard-delete a course — you archive instead. Webinars (VILT) and in-person events (ILT) cannot be made with the create endpoint; they use dedicated bulk-import routes. Details in each section below.
1. Overview and prerequisites
The two endpoints that do almost everything:
| Action | Method + path |
|---|---|
| Create up to 100 courses (each with its full hierarchy) | POST /incoming/v2/content/course/create |
| Update existing content, or append sub-entities, or archive | PUT /incoming/v2/content/course/update |
Base URL. All paths in this tutorial are relative to your instance's incoming API base:
https://{your-instance}.thoughtindustries.com/incoming/v2
Authentication. Every /v2/ route is authenticated with a bearer API key:
Authorization: Bearer YOUR_API_KEY
Generate a key in the admin panel under Settings → Security. Pass it as the second whitespace-delimited token of the Authorization header (i.e. Bearer <key>). See Authentication for key management.
Permissions. The key's role must grant:
courses.new— to callcourse/create.courses.edit— to callcourse/update(including archive).courses.edit.settings— to publish/unpublish viareleaseContent.
Rate limits. course/create and course/update are not rate-limited. A few routes an automation typically touches alongside them are throttled (userAPI tier: 20/min default, 250/min with increasedAPIRateLimits) — notably GET /v2/jobs/{id} (job polling) and GET /v2/content (content search). See Rate limits for the full table and backoff code.
A throttled route returns HTTP 429 with this body and headers:
{ "errors": ["Rate limit exceeded, retry in 2 seconds"] }
x-ratelimit-limit: 20
x-ratelimit-remaining: 0
x-ratelimit-reset: 1785438355
retry-after: 12
retry-after is in seconds and is the value to honor.
Related training: API Fundamentals — Thought Industries Academy — a 5-minute course covering the basics of API fundamentals.
2. The content hierarchy
A single course/create call builds an entire tree:
Course Group (the catalog listing — title, description, pricing, tags, images)
└── Course (a "session" — one is auto-created per group on create)
└── Section
└── Lesson
└── Topic (the actual pages: text, video, audio, SCORM, PDF, quiz, …)
- Course Group = the catalog entity learners browse and enroll in.
- Course (session) = a runnable instance of the group. On create, a group is created with exactly one session; you can add more sessions later via
course/update. - Section → Lesson → Topic = the ordered structure inside a session.
You don't have to build the whole tree at once. You can create a bare course group and then append sections/lessons/topics later with course/update (see §8).
A create with no sections and no topics is valid: the platform auto-creates one section and one lesson, both titled Main, so the session is never structurally empty. Reading back a {"title": "…", "kind": "courseGroup"} create gives:
{
"id": "d2e3f4a5-b6c7-8901-defa-234567890123",
"title": "Workplace Safety Basics",
"status": "draft",
"sections": [
{
"id": "1a2b3c4d-0000-0000-0000-000000000021",
"title": "Main",
"releaseAt": null,
"lessons": [
{ "id": "1a2b3c4d-0000-0000-0000-000000000022", "title": "Main", "accessLevel": null, "topics": [] }
]
}
],
"completionCriteria": []
}
Limits to keep in mind. All four messages below are returned as plain text, not JSON — see §10:
- Up to 100 courses per
createrequest →Cannot create more than 100 courses at a time - Up to 25 children per course (sections + lessons + topics combined) →
Course at index 0 exceeds maximum of 25 children (sections + lessons + topics). Found 26 children. - An empty
courseAttributesarray — or a body with nocourseAttributeskey at all — returnsNo items provided updateaccepts up to 100 total items across all entity types combined →Cannot update more than 100 items at a time
3. Supported course kinds
The required kind field takes exactly five values on create. This is the single most common source of errors — there is no courseType field, and values like learning_path, webinar, scorm, or xapi are not valid kinds. (See also the Supported course kinds reference.)
kind | What it creates | Notes |
|---|---|---|
courseGroup | A full multi-section course (group + session + sections[]) | The general-purpose kind; build the whole tree with sections. |
microCourse | A short course whose topics live in a top-level topics[] (no sections/lessons) | Use topics directly on the course object. |
article | A single article page | Provide articleVariant.body. |
video | A standalone video course | Provide course-level videoUrl (upload) or videoAsset (existing Wistia media). |
shareableContentObject | A standalone SCORM course | Provide course-level scormUrl. |
Not kinds (common mistakes):
xApiObjectis a topic type, not a course kind. (You add xAPI as a topic inside a lesson.)- A learning path is a separate content type, not a course kind.
- Webinar (VILT) and in-person event (ILT) are created only through the bulk-import routes in §11.
Sending an unsupported kind returns HTTP 400. The message begins with Invalid course kinds: and ends with the list of supported kinds:
{
"errors": [
"Invalid course kinds: … Supported kinds are: article, video, courseGroup, microCourse, shareableContentObject"
]
}
Match on the Invalid course kinds: prefix in your error handling rather than trying to parse the offending value back out of the message.
Not every bad kind reaches that check. Values that exist elsewhere in the platform's type system (for example xApiObject, which is a real topic type) produce the message above. Values that are not a known type anywhere (for example learning_path, webinar, or scorm) are rejected one layer earlier and return a generic processing error instead:
{ "errors": [ { "message": "A processing error occurred. Please refresh the page and try again.", "extensions": {} } ] }
Because a bad kind may return either message, validate kind against the five supported values client-side before sending.
4. Your first course (minimal create)
The smallest valid create needs only title and kind. Here is a courseGroup with one section, one lesson, and one text topic:
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Introduction to Marketing",
"kind": "courseGroup",
"sections": [
{
"title": "Getting Started",
"lessons": [
{
"title": "Welcome",
"openType": "studentsOnly",
"topics": [
{
"title": "Course Overview",
"type": "text",
"body": "<p>Welcome to the course!</p>"
}
]
}
]
}
]
}
]
}'
The request body is always { "courseAttributes": [ ... ] } — an array, even for one course. Each courseAttributes entry is one course.
Response — HTTP 200 (not 201), with an object containing the IDs it created (UUIDs), not the full course object:
{
"courseIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"courseGroupIds": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"]
}
courseGroupIds— the catalog entity IDs (one percourseAttributesentry).courseIds— the session IDs (one per group on create).backgroundJob— only present when the request queued an asynchronous upload (SCORM, xAPI, PDF, video-from-URL, audio, or images). See §9.
There is no externalId, no slug-on-create, no authors, no source, and no isFree input — and no courseType either. Sending any of those six field names on an otherwise-valid course returns 400 with the generic processing-error message, because an unrecognized field is rejected before validation runs. Slugs are auto-generated from title on create (editable later via update). Pricing uses priceInCents / freeWithRegistration / purchasable.
5. Field reference (the essentials)
This is the working subset most integrations need. The complete tables (every catalog, session, section, lesson, and topic field) live in the Create courses and Update courses reference pages — link there rather than memorizing everything here.
Course-level (top of each courseAttributes entry)
| Field | Type | Notes |
|---|---|---|
title | string | Required. Omitting it returns 400 with the generic message only, so validate client-side. |
kind | string | Required. One of the 5 kinds (§3). Omitting it returns the same generic 400. |
sku | string | Free-form SKU. Not enforced unique — a second course with an SKU already in use is accepted. |
sections | array | For courseGroup kind. |
topics | array | For microCourse kind (top-level topics). |
articleVariant | object | For article kind ({ "body": "…" }). |
scormUrl | URL | For standalone shareableContentObject. |
videoUrl / videoAsset | URL / ID | For standalone video (mutually exclusive). |
enrollmentLimit, enrollmentStartDate, enrollmentEndDate, courseStartDate, courseEndDate, gracePeriodEndDate | int / date | Enrollment + scheduling. Dates are ISO 8601, and their ordering is validated — see below. |
discussionsEnabled, availableToPublic | boolean | Feature toggles. |
The date fields are cross-validated, with a readable message. Sending enrollmentEndDate after courseEndDate returns 400:
{ "errors": ["Invalid course dates: The course end date must be after or equal to the course enrollment end date"] }
Catalog (course-group) attributes
| Field | Type | Notes |
|---|---|---|
description | string | Max 5,000 chars → {"errors":["Description exceeds maximum length of 5,000 characters"]} |
tagIds | array | UUIDs of pre-existing tags, max 10. Tags are not auto-created; use GET /tags to list valid IDs. |
customFields | JSON object | { "slug": "value" } or { "slug": ["v1","v2"] }. Values must be string / string[] / null. Replaces the whole object on update. |
asset / detailAsset | URL | Catalog & detail images. Queues a background job — one contentUploads entry per image field (§9). assetAltText / detailAssetAltText for alt text. |
ribbon | string | Ribbon badge slug; must exist in the company config. |
metaTitle / metaDescription | string | SEO (max 200 / 500 chars) → {"errors":["metaTitle exceeds maximum length of 200 characters"]} |
contentType | string | Content-type label; auto-derived from kind if omitted: courseGroup → Course, microCourse → MicroCourse, article → Article, video → Video, shareableContentObject → SCORM Course. |
relatedCourseGroupIds | array | UUIDs; must exist on the company. |
The catalog validations all return readable messages in the JSON errors-array-of-strings shape:
{ "errors": ["Some tag IDs were not found"] }
{ "errors": ["Cannot assign more than 10 tags per course. Please reduce your selection."] }
{ "errors": ["customFields values must be string, string array, or null (field \"department\" has type number)"] }
{ "errors": ["Invalid ribbon slug 'not-a-ribbon'. Available ribbons: new-release"] }
{ "errors": ["Invalid relatedCourseGroupIds: 00000000-0000-0000-0000-000000000000. Course groups not found or not accessible."] }
Handy detail: the ribbon error enumerates the valid ribbon slugs for your instance, so a deliberately bogus ribbon is the quickest way to discover them.
Session (course) attributes
| Field | Type | Notes |
|---|---|---|
status | string | Omit → draft. Accepted values: draft, authoring, published, loginRestriction, archived, pending, deleted. With futurePublishDate (and no status), becomes pending. |
priceInCents | integer | e.g. 9900 = $99.00. Max 99,999,999; not negative. |
freeWithRegistration | boolean | Default false. |
purchasable | boolean | Default false. |
futurePublishDate / publishDate | date | Scheduled publish / catalog ordering date. |
forceLinearProgress | boolean | Force in-order completion (default false). |
showProgress | boolean | Show progress indicator (default true). |
prerequisiteCourseIds / prerequisiteLearningPathIds | array | UUIDs; must exist. |
status: "archived" is accepted on create: the session is created already out of circulation, and reading it back returns "status": "archived". To take existing content down, archive the course group via update instead (§8).
Status-related validations:
{ "errors": ["Invalid status 'zzz'. Valid values are: draft, authoring, published, loginRestriction, archived, pending, deleted"] }
{ "errors": ["Cannot set futurePublishDate when status is published."] }
{ "errors": ["priceInCents cannot be negative"] }
{ "errors": ["priceInCents exceeds maximum allowed value of 99999999 ($999,999.99)"] }
futurePublishDate and status: "published" are mutually exclusive: send futurePublishDate on its own and the session comes back as "status": "pending"; send both and you get the 400 above.
Section / Lesson / Topic
| Object | Required fields | Key optional fields |
|---|---|---|
| Section | title, lessons | releaseDate |
| Lesson | title, openType, topics | — |
| Topic | title, type | body, languages, plus type-specific fields (see §7) |
openType (lesson access level) is one of: studentsOnly, open, emailCaptureOpen. There is no openToAll or instructorsOnly; sending one returns 400 with the generic message and no enum hint, so validate openType client-side.
languages on a topic takes an array of per-language bodies, e.g. "languages": [{ "language": "en", "body": "<p>hello</p>" }].
6. Worked examples by kind
Each example is a complete, copy-pasteable request plus the response shape.
6.1 courseGroup — full course with mixed topics
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Introduction to Marketing",
"kind": "courseGroup",
"sku": "MKT-101",
"description": "Marketing fundamentals for new hires.",
"discussionsEnabled": true,
"sections": [
{
"title": "Getting Started",
"lessons": [
{
"title": "Welcome",
"openType": "studentsOnly",
"topics": [
{ "title": "Course Overview", "type": "text", "body": "<p>Welcome!</p>" },
{
"title": "Embedded Media",
"type": "embed",
"body": "<iframe src=\"https://player.vimeo.com/video/123456\" width=\"640\" height=\"360\"></iframe>"
}
]
}
]
},
{
"title": "Core Concepts",
"lessons": [
{
"title": "Marketing Fundamentals",
"openType": "studentsOnly",
"topics": [
{ "title": "The 4 Ps of Marketing", "type": "article", "body": "<h2>Product, Price, Place, Promotion</h2>" },
{ "title": "Knowledge Check", "type": "quiz" }
]
}
]
}
]
}
]
}'
{
"courseIds": ["c3d4e5f6-a7b8-9012-cdef-345678901234"],
"courseGroupIds": ["d4e5f6a7-b8c9-0123-defa-456789012345"]
}
Note the quiz topic is created as a shell — the page exists, but its questions must be authored in the admin UI (§7).
Reading the session back with GET /v2/courses/{courseId}/structure confirms the whole tree, and is how you get the child IDs you'll need for later updates:
{
"id": "c3d4e5f6-a7b8-9012-cdef-345678901234",
"title": "Introduction to Marketing",
"status": "draft",
"sections": [
{
"id": "1a2b3c4d-0000-0000-0000-000000000001",
"title": "Getting Started",
"releaseAt": null,
"lessons": [
{
"id": "1a2b3c4d-0000-0000-0000-000000000002",
"title": "Welcome",
"accessLevel": null,
"topics": [
{ "id": "1a2b3c4d-0000-0000-0000-000000000003", "title": "Course Overview", "type": "text", "editableByChildren": false },
{ "id": "1a2b3c4d-0000-0000-0000-000000000004", "title": "Embedded Media", "type": "embed", "editableByChildren": false }
]
}
]
},
{
"id": "1a2b3c4d-0000-0000-0000-000000000005",
"title": "Core Concepts",
"releaseAt": null,
"lessons": [
{
"id": "1a2b3c4d-0000-0000-0000-000000000006",
"title": "Marketing Fundamentals",
"accessLevel": null,
"topics": [
{ "id": "1a2b3c4d-0000-0000-0000-000000000007", "title": "The 4 Ps of Marketing", "type": "article", "editableByChildren": false },
{ "id": "1a2b3c4d-0000-0000-0000-000000000008", "title": "Knowledge Check", "type": "quiz", "editableByChildren": false }
]
}
]
}
],
"completionCriteria": []
}
6.2 microCourse — top-level topics, no sections
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Quick Tips: Email Etiquette",
"kind": "microCourse",
"topics": [
{ "title": "Subject Lines", "type": "text", "body": "<p>Keep subject lines clear.</p>" },
{ "title": "Professional Tone", "type": "text", "body": "<p>Stay professional.</p>" },
{ "title": "Quiz", "type": "quiz" }
]
}
]
}'
{
"courseIds": ["e5f6a7b8-c9d0-1234-efab-567890123456"],
"courseGroupIds": ["f6a7b8c9-d0e1-2345-fabc-678901234567"]
}
6.3 article — a single article page
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Best Practices for Remote Work",
"kind": "article",
"articleVariant": {
"body": "<h1>Working from Home</h1><p>Here are the best practices…</p>"
}
}
]
}'
{
"courseIds": ["a7b8c9d0-e1f2-3456-abcd-789012345678"],
"courseGroupIds": ["b8c9d0e1-f2a3-4567-bcde-890123456789"]
}
6.4 shareableContentObject — standalone SCORM (queues a background job)
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Compliance Training Module",
"kind": "shareableContentObject",
"sku": "COMP-2024",
"width": 1024,
"height": 768,
"embeddedEnabled": true,
"scormUrl": "https://example.com/scorm-packages/compliance-2024.zip"
}
]
}'
Because the SCORM package is fetched asynchronously, the response carries a backgroundJob:
{
"courseIds": ["c9d0e1f2-a3b4-5678-cdef-901234567890"],
"courseGroupIds": ["d0e1f2a3-b4c5-6789-defa-012345678901"],
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e01",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{
"topicId": "1a2b3c4d-0000-0000-0000-000000000011",
"courseId": "c9d0e1f2-a3b4-5678-cdef-901234567890",
"contentType": "scorm"
}
]
}
}
scormUrl is accepted and queued before the platform has fetched the package, so a 200 here means "the URL was accepted", not "this is a valid SCORM package". Only the background job can tell you that (§9). The shareableContentObject kind also auto-creates the containing section/lesson/topic for you; the returned contentUploads[].topicId is that generated topic.
6.5 video — upload from URL vs. existing Wistia media
Video fields go at the course (top) level, not inside topics. videoUrl and videoAsset are mutually exclusive, and assetType cannot be combined with videoUrl. Both violations return 400, but with the generic message (as a bare string in the errors array), so there is no machine-readable signal of which rule you broke:
{ "errors": ["A processing error occurred. Please refresh the page and try again."] }
Upload from URL (downloaded to Wistia in a background job):
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Welcome Video",
"kind": "video",
"sku": "VID-001",
"videoUrl": "https://example.com/videos/welcome.mp4",
"posterImageAsset": "https://example.com/images/welcome-poster.jpg"
}
]
}'
{
"courseIds": ["e1f2a3b4-c5d6-789a-efab-123456789012"],
"courseGroupIds": ["f2a3b4c5-d6e7-89ab-fabc-234567890123"],
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e02",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{ "topicId": "1a2b3c4d-0000-0000-0000-000000000012", "courseId": "e1f2a3b4-c5d6-789a-efab-123456789012", "contentType": "video" }
]
}
}
Note there is one contentUploads entry, for the video. posterImageAsset does not produce its own upload entry, unlike the catalog asset / detailAsset fields, which each do (§9).
Reuse existing Wistia media (videoAsset; no background job):
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Product Demo",
"kind": "video",
"videoAsset": "abc123wistia",
"preAsset": "intro456wistia",
"postAsset": "outro789wistia"
}
]
}'
{
"courseIds": ["a3b4c5d6-e7f8-9012-abcd-345678901234"],
"courseGroupIds": ["b4c5d6e7-f8a9-0123-bcde-456789012345"]
}
There is no backgroundJob here — but there is also no validation of the IDs themselves. The API does not check that a videoAsset / preAsset / postAsset hash exists in Wistia, so a typo returns 200 and produces a silently broken video page. Confirm the IDs before you send them.
6.6 audio topic — supply an audio file by URL
Audio content is API-populatable via the audioUrl field on a topic whose type is audio. The platform downloads the file and stores it in a background job. Supported formats: MP3, WAV, OGG (max 200 MB); the URL must be HTTP/HTTPS.
curl -X POST "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/create" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": [
{
"title": "Language Lessons",
"kind": "courseGroup",
"sections": [
{
"title": "Pronunciation",
"lessons": [
{
"title": "Vowel Sounds",
"openType": "studentsOnly",
"topics": [
{
"title": "Listen: Vowel Sounds",
"type": "audio",
"audioUrl": "https://example.com/audio/vowel-sounds.mp3"
}
]
}
]
}
]
}
]
}'
{
"courseIds": ["c5d6e7f8-a9b0-1234-cdef-567890123456"],
"courseGroupIds": ["d6e7f8a9-b0c1-2345-defa-678901234567"],
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e03",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{ "topicId": "1a2b3c4d-0000-0000-0000-000000000013", "courseId": "c5d6e7f8-a9b0-1234-cdef-567890123456", "contentType": "audio" }
]
}
}
audioUrl is only valid on topics with "type": "audio". A mismatch returns HTTP 400 with a precise message that includes the path to the offending topic. The body is plain text, not JSON:
Cannot use 'audioUrl' with topic type 'text' for topic at course[0].sections[0].lessons[0].topics[0]. This field is only valid for topics with type 'audio'.
For a microCourse, where topics are top-level, the path is shorter:
Cannot use 'audioUrl' with topic type 'text' for topic at course[0].topics[0]. This field is only valid for topics with type 'audio'.
The file itself is validated later, during the download, not at request time — a URL that isn't audio still returns 200 here and fails in the background job (§9).
7. Topic types
Topics are the pages inside a lesson. Some types accept content over the API; others create a shell (placeholder) that must be finished in the admin UI.
Populatable via API:
type | Field to populate |
|---|---|
text | body (HTML) |
article | body or languages |
video | videoUrl (upload) or videoAsset (existing) |
audio | audioUrl |
shareableContentObject | scormUrl |
xApiObject | scormUrl |
pdfViewer | pdfUrl |
embed | body (iframe) |
discussionBoard | — (forum) |
Shell only (created empty; authored in the UI): quiz, test, survey, assignment, image, slideshow, presentation, meetingInfo (and other assessment/interactive types such as workbook, bongo, lti, listRoll, tally, flipCardSet, notebook).
A single request can mix both groups freely. One containing pdfViewer + xApiObject + shareableContentObject + discussionBoard topics alongside seven shell types returns 200, with one contentUploads entry per URL-fed topic:
{
"courseIds": ["e7f8a9b0-c1d2-3456-efab-789012345678"],
"courseGroupIds": ["f8a9b0c1-d2e3-4567-fabc-890123456789"],
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e04",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{ "topicId": "1a2b3c4d-0000-0000-0000-000000000014", "courseId": "e7f8a9b0-c1d2-3456-efab-789012345678", "contentType": "pdf" },
{ "topicId": "1a2b3c4d-0000-0000-0000-000000000015", "courseId": "e7f8a9b0-c1d2-3456-efab-789012345678", "contentType": "xapi" },
{ "topicId": "1a2b3c4d-0000-0000-0000-000000000016", "courseId": "e7f8a9b0-c1d2-3456-efab-789012345678", "contentType": "scorm" }
]
}
}
So contentType in contentUploads is pdf / xapi / scorm / video / audio / image — not the topic type string.
Put type-specific fields on the matching topic
type.audioUrlis the only type-specific upload field the platform validates against the topic type. Any other mismatch —pdfUrlon a"type": "text"topic, for example — is accepted with200and then silently ignored: no error, no background job, and no content on the page. Make sure the field and thetypeagree before you send the request.
8. Updating content
PUT /content/course/update does three jobs: partial update, append new sub-entities, and archive. Its body shape differs from create — it is an object keyed by entity type, not an array:
{
"courseAttributes": {
"courseGroups": [ ],
"courses": [ ],
"sections": [ ],
"lessons": [ ],
"topics": [ ]
}
}
Only the fields you send are changed. A simple response is the bare JSON literal true (HTTP 200); if the update queues an upload it returns { "success": true, "backgroundJob": { … } }. A client must handle either a boolean or an object from this endpoint.
Partial update (change a title)
curl -X PUT "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/update" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": {
"courseGroups": [
{ "id": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "title": "Updated Course Title" }
]
}
}'
true
Append new sub-entities (omit id)
To create a new entity instead of updating one, omit its id and supply the required parent ID:
| Creating a… | Omit id, provide parent |
|---|---|
| Course (session) | courseGroupId |
| Section | courseId |
| Lesson | sectionId |
| Topic | lessonId + type |
curl -X PUT "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/update" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": {
"sections": [
{ "courseId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "title": "New Section", "displayOrder": 3 }
]
}
}'
true
All four append forms return true: a new section under a courseId, a new lesson under a sectionId, a new topic under a lessonId (with type), and a new session under a courseGroupId. Re-read GET /v2/courses/{courseId}/structure afterwards to see the appended children in place — that's also the only way to get their new IDs, since the update response doesn't return them.
Replace SCORM on an existing topic
curl -X PUT "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/update" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": {
"topics": [
{
"id": "1a2b3c4d-0000-0000-0000-000000000011",
"scormUrl": "https://example.com/scorm-packages/compliance-2025.zip",
"restartProgress": true
}
]
}
}'
{
"success": true,
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e06",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{ "topicId": "1a2b3c4d-0000-0000-0000-000000000011", "contentType": "scorm" }
]
}
}
restartProgress: true resets learner progress when the package is replaced. This returns a backgroundJob (the new package is fetched asynchronously).
Watch the contentUploads shape: on update the entry has only topicId + contentType, with no courseId — unlike the create response, where courseId is present. Don't write a parser that requires courseId.
Publish / unpublish
Publishing is a separate endpoint:
curl -X PUT "https://{your-instance}.thoughtindustries.com/incoming/v2/content/<id>/releaseContent" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "kind": "course", "action": "release" }'
{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "published" }
kindiscourseorlearningPath;actionisreleaseorunrelease.unreleasereturns{ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "draft" }.- Requires the
courses.edit.settingspermission. - Note the
{id}in the path is the session (course) ID, not the course-group ID.
Archive instead of delete
There is no REST endpoint to hard-delete a course. To take a course out of circulation, archive its course group via update:
curl -X PUT "https://{your-instance}.thoughtindustries.com/incoming/v2/content/course/update" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"courseAttributes": {
"courseGroups": [
{ "id": "e3f4a5b6-c7d8-9012-efab-345678901234", "archived": true }
]
}
}'
true
Archiving a group also archives its child courses. Reading a child session back afterwards shows:
{ "id": "d2e3f4a5-b6c7-8901-defa-234567890123", "title": "Workplace Safety Basics", "status": "archived" }
Set archived: false to restore it. Restore does not remember the previous status — the child session comes back as draft, even if it was published before being archived. If the course was live, re-publish it via releaseContent after restoring.
9. Background jobs
Inputs that require fetching a remote asset queue an asynchronous contentBulkUpload job: SCORM (scormUrl), xAPI, PDF (pdfUrl), video-from-URL (videoUrl), audio (audioUrl), and catalog/detail images. When that happens, the create/update response includes a backgroundJob with a job id.
The contentUploads array has two entry shapes
Topic-level uploads are keyed by topic; catalog images are keyed by course group and name the field they came from. A single create that sets both asset and detailAsset returns:
{
"courseIds": ["a9b0c1d2-e3f4-5678-abcd-901234567890"],
"courseGroupIds": ["b0c1d2e3-f4a5-6789-bcde-012345678901"],
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e05",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{ "courseGroupId": "b0c1d2e3-f4a5-6789-bcde-012345678901", "contentType": "image", "fieldName": "asset" },
{ "courseGroupId": "b0c1d2e3-f4a5-6789-bcde-012345678901", "contentType": "image", "fieldName": "detailAsset" }
]
}
}
So a contentUploads entry is either {topicId, courseId, contentType} (create, topic asset), {topicId, contentType} (update, topic asset), or {courseGroupId, contentType, fieldName} (catalog image). Treat every key as optional.
Polling
Poll the job until it finishes before treating the content as ready:
curl "https://{your-instance}.thoughtindustries.com/incoming/v2/jobs/<job-id>" \
-H "Authorization: Bearer YOUR_API_KEY"
The response is a flat object — the job fields are at the top level, with no envelope around them:
{
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e01",
"description": "Upload 1 content file for Example Company",
"errorMessage": null,
"status": "queued"
}
- Four fields only:
id,description,errorMessage,status. There is nocontentUploadson the read side — the job read tells you the aggregate outcome, not per-file detail. descriptionis human-readable and counts the files (Upload 3 content files for <Company Name>), which is a useful sanity check that the platform saw everything you sent.errorMessageisnull(not"") while queued and on success; on failure it carries the reason.- Terminal states are
completeandfailed. GET /v2/jobs/{id}is rate-limited (userAPI, 20/min by default). Honorretry-after(see Rate limits and the Background jobs reference).
A polling loop:
while :; do
RESP=$(curl -s -w '\n%{http_code}' "$BASE/jobs/$JOB_ID" -H "Authorization: Bearer $KEY")
CODE=$(printf '%s' "$RESP" | tail -1)
BODY=$(printf '%s' "$RESP" | sed '$d')
if [ "$CODE" = "429" ]; then sleep 15; continue; fi # or read retry-after
STATUS=$(printf '%s' "$BODY" | jq -r '.status')
case "$STATUS" in
complete) break ;;
failed) echo "job failed: $(printf '%s' "$BODY" | jq -r '.errorMessage')"; exit 1 ;;
esac
sleep 10
done
Timing
How long a job takes depends on the size of the asset, the depth of the upload queue, and the environment your instance runs on. Don't design around a fixed duration:
- Poll with backoff, honoring
retry-afteron429, and give the loop a sensible overall timeout. status: "queued"is not an error signal. The response carries no timestamp, attempt count, or queue position, so treatqueuedas "still working".- If the timeout is reached, surface the job ID for follow-up — don't re-send the create, which would duplicate the course (§11).
- Don't gate a user-facing "course ready" state on job completion inside a synchronous request.
- The synchronous
200from create/update means the URL was accepted and the job was enqueued. Package and file validity are decided later, in the job.
10. Validation and errors
Validation failures return HTTP 400. There is no code field, no statusCode in the body, and no MISSING_REQUIRED_FIELD / DUPLICATE_SLUG / INVALID_FIELD_VALUE style codes.
Error responses take several forms depending on which layer rejects the request. Handle all of them, and don't assume the body is JSON.
The four 400 shapes
1. JSON, errors as an array of readable strings. The good case — business-rule validation in the controller.
{ "errors": ["Some tag IDs were not found"] }
2. Plain text, no JSON at all. A bare string body — JSON.parse on it throws.
Cannot create more than 100 courses at a time
The plain-text responses are: No items provided, Cannot create more than 100 courses at a time, Cannot update more than 100 items at a time, Course at index 0 exceeds maximum of 25 children (sections + lessons + topics). Found 26 children., and the Cannot use 'audioUrl' with topic type … message.
3. JSON, errors as an array of objects, carrying only a generic message. This is the schema/parse layer rejecting the request before any business validation runs, so the message doesn't identify the problem.
{ "errors": [ { "message": "A processing error occurred. Please refresh the page and try again.", "extensions": {} } ] }
The array length loosely tracks how many things were rejected (two or three identical entries are common), but the entries are indistinguishable.
4. JSON, errors as an array containing the generic message as a bare string. The same generic failure, differently serialized.
{ "errors": ["A processing error occurred. Please refresh the page and try again."] }
Which triggers give you a specific message, and which don't
| Trigger | Message |
|---|---|
tagIds unknown / not yours | Some tag IDs were not found |
More than 10 tagIds | Cannot assign more than 10 tags per course. Please reduce your selection. |
customFields value of the wrong type | customFields values must be string, string array, or null (field "department" has type number) |
Bad ribbon slug | Invalid ribbon slug 'not-a-ribbon'. Available ribbons: new-release |
Bad relatedCourseGroupIds | Invalid relatedCourseGroupIds: <id>. Course groups not found or not accessible. |
Bad status value | Invalid status 'zzz'. Valid values are: draft, authoring, published, loginRestriction, archived, pending, deleted |
futurePublishDate + status: published | Cannot set futurePublishDate when status is published. |
Negative / oversized priceInCents | priceInCents cannot be negative / priceInCents exceeds maximum allowed value of 99999999 ($999,999.99) |
description / metaTitle too long | Description exceeds maximum length of 5,000 characters / metaTitle exceeds maximum length of 200 characters |
| Inconsistent course/enrollment dates | Invalid course dates: The course end date must be after or equal to the course enrollment end date |
audioUrl on a non-audio topic | Cannot use 'audioUrl' with topic type 'text' for topic at course[0].sections[0].lessons[0].topics[0]. … (plain text) |
Over 100 courses / over 25 children / empty courseAttributes / over 100 update items | plain-text limit messages (see above) |
Unknown topic id on update | Topics not found: 00000000-0000-0000-0000-000000000000 |
Missing title | Generic processing-error message only |
Missing kind | Generic processing-error message only |
kind not a known platform type (learning_path, webinar, …) | Generic processing-error message only |
Invalid openType (e.g. openToAll) | Generic processing-error message only |
videoUrl + videoAsset together | Generic processing-error message only |
assetType + videoUrl together | Generic processing-error message only |
| Any unrecognized field anywhere in the payload | Generic processing-error message only |
When you get the generic processing-error message, re-check the payload against the rules in this page rather than looking for a more specific message — there isn't one. The practical rule: validate required fields and enum values client-side, since the API will stop a bad request but won't always tell you which mistake you made.
Index prefixing in multi-course batches
Some validations name the offending entry. A batch whose third entry exceeds the child limit returns:
Course at index 2 exceeds maximum of 25 children (sections + lessons + topics). Found 26 children.
Two things to design for:
- Errors are not accumulated. The first failing check stops the whole request, so a batch with problems in two entries reports only the first one. Fixing one reported error can reveal the next — retry loops should expect several rounds.
- Not every message is index-prefixed. A bad
tagIds, for instance, returns the unprefixedSome tag IDs were not found. Where the prefix is present, the format isCourse at index N <message>(no colon).
Create is atomic
A batch that fails validation creates nothing — including the entries that were valid. Fix and resubmit the whole batch. See Status codes for HTTP-level details.
11. Creating at scale (bulk)
Native bulk in one request
course/create is already a bulk endpoint: put up to 100 courses in courseAttributes[] and they're created in a single call. You do not need to loop one course per request.
Because the batch is atomic (all-or-nothing), prefer small batches of 10–25 in production so a single bad record doesn't force you to resubmit hundreds of courses, and so the index-prefixed error points at a short list.
There is no server-side idempotency
The API has no externalId or idempotency key. Re-sending the same batch creates duplicate courses. To make an integration safe to retry, you must:
- Keep your own map of
your-external-id → courseGroupId/courseIdfrom the returned IDs. - Before creating, check whether the item already exists (see §13.2 for which read endpoints to use).
- Use
course/updatefor anything that already exists.
Webinars (VILT) and in-person events (ILT)
These kinds are not creatable through course/create. Use the dedicated bulk-import routes:
| Method | Path |
|---|---|
| POST | /content/session/webinar/bulk |
| POST | /content/session/inPersonEvent/bulk |
| POST | /content/courseGroup/webinar/bulk |
| POST | /content/courseGroup/inPersonEvent/bulk |
Body: { "bulkImportAssetPath": "<url-to-import-file>" }. All four return the same shape:
{
"dryRun": false,
"backgroundJob": {
"id": "7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e07",
"description": "Bulk import webinars from API for Example Company",
"errorMessage": null,
"status": "queued"
}
}
Two things to note:
- The
backgroundJobhere is the flat four-field job object (the same shapeGET /v2/jobs/{id}returns), not thecontentBulkUploadobject withtypeandcontentUploadsthatcourse/createreturns. Poll it the same way as any other job (§9). - The import file at
bulkImportAssetPathis not fetched or checked synchronously, so a200only means the request was accepted. Problems with the file surface in the background job.
12. Using Postman
You can drive the whole create → poll → update → publish flow from Postman.
12.1 Set up an environment
Create a Postman Environment with two variables:
| Variable | Example value |
|---|---|
baseUrl | https://{your-instance}.thoughtindustries.com/incoming/v2 |
apiKey | your secret API key (mark it secret) |
Set collection-level Authorization to Bearer Token with the token {{apiKey}} so every request inherits it. (Equivalently, add a header Authorization: Bearer {{apiKey}}.)
12.2 Importing / starter requests
Point Postman at the published API reference at https://api.thoughtindustries.com/ to import the schema, or create these requests by hand:
- Create course —
POST {{baseUrl}}/content/course/create, body = raw JSON from §4. - Poll job —
GET {{baseUrl}}/jobs/{{jobId}}. - Update course —
PUT {{baseUrl}}/content/course/update. - Publish —
PUT {{baseUrl}}/content/{{courseId}}/releaseContent, body{ "kind": "course", "action": "release" }.
12.3 Chain returned IDs with a test script
On the Create course request, add a Post-response (Tests) script to capture the returned IDs into environment variables so later requests can reuse them:
const res = pm.response.json();
if (res.courseGroupIds && res.courseGroupIds.length) {
pm.environment.set("courseGroupId", res.courseGroupIds[0]);
}
if (res.courseIds && res.courseIds.length) {
pm.environment.set("courseId", res.courseIds[0]);
}
if (res.backgroundJob && res.backgroundJob.id) {
pm.environment.set("jobId", res.backgroundJob.id);
}
The backgroundJob guard matters: plain creates with no remote assets omit the key entirely.
If you add a test on the Poll job request, read status from the top level of the response — pm.response.json().status (§9). Upload jobs can take a while, so don't wire polling into a Runner step that has to finish quickly.
Now the Publish request can target {{baseUrl}}/content/{{courseId}}/releaseContent and the Poll job request can hit {{baseUrl}}/jobs/{{jobId}}.
12.4 Collection Runner with a CSV → courseAttributes batches
To create many courses from a spreadsheet, use the Collection Runner with a data file. A simple approach maps one CSV row → one create request:
CSV (courses.csv):
title,kind,sku
Intro to Sales,courseGroup,SALES-101
Onboarding Basics,microCourse,ONB-001
Compliance 2024,shareableContentObject,COMP-2024
On the Create course request, use a Pre-request script to assemble the body from the current row and store it, then reference it in the request body:
// Pre-request: build a one-course courseAttributes array from the CSV row
const course = { title: pm.iterationData.get("title"), kind: pm.iterationData.get("kind") };
const sku = pm.iterationData.get("sku");
if (sku) course.sku = sku;
pm.variables.set("courseBody", JSON.stringify({ courseAttributes: [course] }));
Request body (raw JSON): {{courseBody}}.
To send true batches (up to 100 courses per request instead of one), group rows in code and post the assembled courseAttributes array — Postman's row-per-iteration model is best for modest volumes; for large imports, script the batching in your own client (or use the native bulk array directly).
12.5 Rate-limit note for runs
course/create and course/update aren't throttled, so a create-only run won't hit 429. But if your run also polls GET /jobs/{id} or searches GET /content, those are userAPI (20/min default): past the limit they return 429 with retry-after and x-ratelimit-* headers. Read retry-after and back off — see Rate limits.
13. Automating with AI agents
This section is about an LLM agent orchestrating the real REST endpoints above. Thought Industries does not ship an agent product or an MCP server for this, so an agent integration is ordinary REST calls with good bookkeeping. Ground the agent in the Create courses, Update courses, and Rate limits pages, and in the behavior described below.
13.1 Sequencing
- Prefer one call. When the agent knows the whole structure, send the entire tree in a single
course/create(group → sections → lessons → topics). Fewer calls, atomic validation. - Incremental builds. When building step by step, create the group first, then append sections/lessons/topics with
course/update(omitid, supply the parent ID from §8). Read the current tree back withGET /v2/courses/{courseId}/structure(not throttled) to get child IDs before the next append.
13.2 Idempotency is the integration's job
There is no server-side idempotency key, which makes existence checking the most important thing to get right. The agent must maintain its own external-id → courseGroupId/courseId map and check existence before creating:
- Use an unthrottled read for existence checks:
GET /v2/courseGroups(list),GET /v2/courseGroups/{id}, orGET /v2/courseGroups/slug/{slug}. - If it exists, call
course/update; otherwisecourse/create. Never assume the server will dedupe — re-posting a create makes duplicates.skuwon't save you either: it isn't enforced unique, so a duplicateskuis accepted.
Two details for the existence check:
GET /v2/courseGroups/slug/{slug}returns HTTP200on a miss, not404, with an error envelope and a null payload. Branch on the payload, not the status code:
{
"errors": [ { "message": "CourseGroup not found", "path": ["APICourseGroupBySlug"], "extensions": { "code": "INTERNAL_SERVER_ERROR" } } ],
"data": { "APICourseGroupBySlug": null }
}
- Don't guess the slug. Slug generation strips punctuation in ways that are easy to get wrong:
"Introduction to Marketing (v2.1)"becomesintroduction-to-marketing-v21— the parentheses vanish and the dot closes up. Store the slug returned by a read rather than deriving it from your title.
13.3 Error handling from the real shape
- Handle all four
400shapes from §10, including the plain-text ones — don't assume the body parses as JSON. - For multi-course batches, some messages carry a
Course at index Nprefix; mapNback to the record and correct just that one. Where there's no prefix, and where the message is the generic processing error, re-validate the whole payload. - Because create is atomic, a rejected batch created nothing, so it's safe to fix the flagged record(s) and retry the whole batch.
- Keep batches small (10–25) so a failure is cheap to diagnose and resubmit.
13.4 Rate-limit / backoff
course/createandcourse/updateare unthrottled — the agent can push content fast.- The routes it will hit around creation are throttled at
userAPI(20/min default, 250/min increased):GET /jobs/{id}(polling) andGET /content(search), plus the bulk price/criteria/certificate endpoints. - On
429, readRetry-After(authoritative) and back off with jitter. Mirror the retry helpers already published in Rate limits rather than re-implementing them.
13.5 Wait for async content before "done"
After any SCORM/xAPI/PDF/video-URL/audio/image upload, the response carries a backgroundJob. The agent should poll GET /v2/jobs/{id} — reading status from the top level of the flat job object — until it is complete, checking errorMessage on failed, before reporting the course ready.
Two things an agent in particular needs to get right here:
- Don't treat a long
queuedas a failure. Completion time varies with asset size, queue depth, and environment. An agent that gives up after a fixed timeout and retries the create will produce duplicate courses — the worst failure mode on an endpoint with no idempotency key. Back off, and when a timeout is reached hand the job ID to a human rather than re-creating. - A
200from create is not "content is live". It means the URL was accepted. Package validity is decided in the job.
13.6 A minimal end-to-end orchestration
Using only real endpoints and shapes:
# 1) Create a SCORM course (queues an async upload)
CREATE=$(curl -s -X POST "$BASE/content/course/create" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"courseAttributes":[{"title":"Compliance 2024","kind":"shareableContentObject","scormUrl":"https://your-host.example/pkg.zip"}]}')
COURSE_ID=$(echo "$CREATE" | jq -r '.courseIds[0]')
JOB_ID=$(echo "$CREATE" | jq -r '.backgroundJob.id')
# 2) Poll the background job until it reaches a terminal state.
# The job object is flat: {id, description, errorMessage, status}.
# GET /jobs/{id} is userAPI-throttled (20/min) -> back off on 429.
while :; do
RESP=$(curl -s -w '\n%{http_code}' "$BASE/jobs/$JOB_ID" -H "Authorization: Bearer $KEY")
CODE=$(printf '%s' "$RESP" | tail -1)
BODY=$(printf '%s' "$RESP" | sed '$d')
if [ "$CODE" = "429" ]; then sleep 15; continue; fi
STATUS=$(printf '%s' "$BODY" | jq -r '.status')
case "$STATUS" in
complete) break ;;
failed) echo "upload failed: $(printf '%s' "$BODY" | jq -r '.errorMessage')" >&2; exit 1 ;;
esac
sleep 10
done
# 3) Update a session field (partial update; returns the JSON literal `true`)
curl -s -X PUT "$BASE/content/course/update" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "{\"courseAttributes\":{\"courses\":[{\"id\":\"$COURSE_ID\",\"priceInCents\":9900,\"purchasable\":true}]}}"
# 4) Publish -> {"id":"<courseId>","status":"published"}
curl -s -X PUT "$BASE/content/$COURSE_ID/releaseContent" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"kind":"course","action":"release"}'
($BASE = https://{your-instance}.thoughtindustries.com/incoming/v2, $KEY = your API key.)
Responses for steps 1, 3 and 4:
// 1) create
{"courseIds":["f4a5b6c7-d8e9-0123-fabc-456789012345"],"courseGroupIds":["a5b6c7d8-e9f0-1234-abcd-567890123456"],
"backgroundJob":{"id":"7c9e6f80-3d2a-4b15-9f6e-1a2b3c4d5e08","type":"contentBulkUpload","status":"queued",
"contentUploads":[{"topicId":"1a2b3c4d-0000-0000-0000-000000000031","courseId":"f4a5b6c7-d8e9-0123-fabc-456789012345","contentType":"scorm"}]}}
// 3) update
true
// 4) publish
{"id":"f4a5b6c7-d8e9-0123-fabc-456789012345","status":"published"}
Publishing is not gated on the upload job: step 4 succeeds while the job is still queued. If you publish before the job completes, learners can reach a course whose SCORM package hasn't landed yet. Keep step 2 before step 4, and treat its completion as a real gate.
14. Next steps
- Create courses — the full
POST /v2/content/course/createreference: every course, catalog, session, section, lesson, and topic field. - Update courses — the full
PUT /v2/content/course/updatereference: partial updates, appending sub-entities, and archiving. - Supported course kinds — the five kinds
createaccepts. - Course structure —
GET /v2/courses/{courseId}/structure, how you read child IDs back. - Release or unrelease content — publish and unpublish via
releaseContent. - Background jobs —
GET /v2/jobs/{id}background-job polling. - REST API reference — every REST endpoint, browsable by category.
- Rate limits — throttle tiers, headers, and backoff code.
- Authentication — API keys and bearer auth.
- Status codes — HTTP error reference.