Supported Course Kinds
End-to-end guide for creating and updating courses via the REST API — every supported course kind and topic type.
An end-to-end guide to programmatic course creation and updates with the Thought Industries REST API v2 — from the minimal request to production-grade patterns. It is centered on POST https://example.thoughtindustries.com/incoming/v2/content/course/create and the related course-management endpoints, and is the single authoritative reference for creating and updating courses via the API.
Base URL. All paths use
https://example.thoughtindustries.com/incoming/v2as the instance base-URL placeholder, which expands to your instance's/incoming/v2prefix (e.g.https://yourcompany.thoughtindustries.com/incoming/v2). Example:https://example.thoughtindustries.com/incoming/v2/content/course/create.
Source of truth. Every supported value, field, course kind, and topic/page type in this guide was verified against the Thought Industries monorepo (route map, controllers, GraphQL resolvers, and GraphQL schema) and reconciled with the existing REST reference content. Where a value is in the schema but not accepted by the create endpoint, that distinction is called out explicitly. Anything that could not be verified is flagged as Unverified.
Prerequisites. You need a valid API key with write permissions (
courses.newto create,courses.editto update). Find your key in the platform under Settings > API Access. All requests use theincoming/v2endpoint prefix shown above.
Related training. API Fundamentals — Thought Industries Academy — a 5-minute course covering the basics of API fundamentals.
Content hierarchy
Thought Industries organizes course content as a strict hierarchy. The create endpoint builds the entire hierarchy for you from a single courseAttributes entry — you do not create each level separately on initial create.
| Level | Platform term | Contains | Notes |
|---|---|---|---|
| Course Group | Catalog item | one or more Courses (sessions) | The catalog-visible object. Holds title, description, asset/thumbnail, tags, SEO meta, ribbon, content type. |
| Course | Session | one or more Sections | The enrollable session. Holds pricing, dates, enrollment limits, prerequisites, progress settings. |
| Section | Module | one or more Lessons | Has a title and optional release date. |
| Lesson | Unit | one or more Topics | Has a title and an openType (access level). |
| Topic | Page / learning object | the actual content | Article, video, quiz, SCORM, etc. — see supported topic types below. |
Verification: This is confirmed in the schema input types CourseAttributes → CourseSection → CourseLesson → CourseTopic (gql/common.graphql) and the build logic in lib/create_course.ts. When you create a single courseGroup, the API creates one Course Group containing one Course (session), which contains your sections → lessons → topics.
On create, a Course Group always contains exactly one Course (session). Multi-session course groups (e.g. multiple webinar/ILT sessions under one catalog item) are produced by the dedicated bulk import endpoints, or by adding more
coursesto an existing course group via the update endpoint.
Authentication & permissions
All /incoming/v2/ endpoints require authentication. The course create/update endpoints accept an API key as a bearer token.
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json
Verified details (incoming/index.ts):
- The authentication middleware reads
Authorization: <scheme> <key>and uses the second token as the key.Beareris the documented scheme. - Alternatively the key may be supplied as
secretKeyin the JSON body or as a?secretKey=query parameter; a signed SSO?jwt=is also accepted. Bearer header is recommended. - The key may be the company secret key or a permissioned API key. Permissioned keys are checked against the action's required permission.
Required permissions (verified in the GraphQL resolvers, gql/course.ts):
| Action | Endpoint | Permission |
|---|---|---|
| Create courses | POST https://example.thoughtindustries.com/incoming/v2/content/course/create | courses.new |
| Update courses | PUT https://example.thoughtindustries.com/incoming/v2/content/course/update | courses.edit |
Rate limiting: The course/create and course/update routes are not wired to a throttle middleware (unlike /v2/users, /v2/meetings, and /v2/events/*, which are). Verified by the absence of throttle.* in their route definitions in incoming/index.ts. Standard platform/network limits may still apply, so batch sensibly.
Store keys in environment variables; never embed them in client-side code or commit them to source control.
Supported course kinds
The kind field is required and must be one of exactly five values. This is enforced server-side in the APICreateCourses resolver (gql/course.ts):
[
"article",
"video",
"courseGroup",
"microCourse",
"shareableContentObject"
]kind | What it creates | Structure you supply |
|---|---|---|
courseGroup | Standard multi-section course | sections[] → lessons[] → topics[] |
microCourse | Lightweight course, topics at the top level | topics[] (flattened into a single section/lesson) |
article | Article/blog-style content | articleVariant (one article topic auto-created) |
video | Standalone video learning object | course-level videoUrl or videoAsset (one topic auto-created) |
shareableContentObject | Standalone SCORM package | scormUrl, width, height (one SCORM topic auto-created) |
Important corrections vs. the broader enum
The GraphQL CourseGroupKind enum (gql/common.graphql) contains additional values — webinar, xApiObject, inPersonEvent, webinarCourse, inPersonEventCourse — but these are NOT accepted by course/create. Specifically:
xApiObjectis not a valid coursekind. Sending it returns a 400. xAPI content is added as a topic (type: "xApiObject"with ascormUrl) inside a course of one of the five supported kinds. (Note: the create resolver does contain a code path referencing a standalonexApiObjectcourse for content upload, but the kind-validation gate rejects it before that path is reachable. Treat standalone xAPI courses as unsupported via this endpoint.)webinar/inPersonEvent(VILT and ILT) are created through dedicated bulk-import endpoints, notcourse/create. See Related course-management endpoints below.
Supported topic types
A topic's type must be a value from the TopicType enum (gql/common.graphql). The full enum is:
ad, article, image, slideshow, presentation, quiz, test, survey, text, video,
recipe, assignment, listRoll, lti, tally, flipCardSet, notebook, highlightZoneSet,
shareableContentObject, xApiObject, audio, matchPairSet, highlightZoneQuiz,
socialShareCardSet, surveyGizmo, discussionBoard, embed, workbook, pdfViewer,
inPersonEvent, meetingInfo, bongo
Not every enum value can have its content populated through the API. Based on the create/update logic in lib/create_course.ts and gql/course.ts, topics fall into two practical groups.
Content can be created via API
type | Populate with | Notes |
|---|---|---|
text | body (HTML) | Rich text page. |
article | body and/or languages[] (ArticlePageVariant) | Article page; supports multi-language variants. |
video | videoUrl (upload) or videoAsset (existing Wistia/Synthesia) | See video rules in Validation rules below. |
shareableContentObject | scormUrl (+ optional width, height, embeddedEnabled) | SCORM package; queued to a background job. |
xApiObject | scormUrl | xAPI package; queued to a background job. |
pdfViewer | pdfUrl | PDF; queued to a background job. |
embed | body (e.g. an <iframe>) | Embedded third-party content. |
discussionBoard | — | Discussion forum page (created as a functional page). |
Created as a shell (page exists; configure remaining content/questions in the UI)
type | Why a shell |
|---|---|
quiz, test, survey, workbook | Questions are added via the authoring UI after creation. |
assignment, bongo | Assignment configuration is done in the UI. |
audio | No API field exists to set the audio asset on create/update (the CourseTopic/UpdateCourseTopic inputs have no audio URL field). Unverified for population via API — treat as shell only. |
image, slideshow, presentation, listRoll, flipCardSet, notebook, matchPairSet, highlightZoneSet, highlightZoneQuiz, socialShareCardSet, tally, recipe, lti, ad, surveyGizmo, meetingInfo, inPersonEvent | These types are valid enum values but have no dedicated population fields on the create/update topic inputs. They are created as placeholder pages and configured in the UI. Unverified for full API population. |
The topic input fields that DO exist on every topic are listed under Step 2 — Field reference below. If a field you need for a given page type is not in that list, that content must be configured in the UI.
Create courses
https://example.thoughtindustries.com/incoming/v2/content/course/createThe request body is an object with a courseAttributes array (bulk create). Each entry is one course. The sections below walk through it tutorial-style: a minimal request, the full field reference, a production-ready example, and the success response — followed by validation, errors, and updating.
Step 1 — Minimal creation
At minimum you need a title and a kind. This creates a single draft course group with one session and your supplied structure.
curl -X "POST" "https://example.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!</p>" }
]
}
]
}
]
}
]
}'The response returns the created courseIds and courseGroupIds (see Step 4 — Success response). Store these IDs — you'll need them for updates, structure reads, and enrollments.
Step 2 — Field reference
Verified against input CourseAttributes in gql/common.graphql and the build logic in lib/create_course.ts.
Required
| Field | Type | Notes |
|---|---|---|
title | string | Course title. |
kind | enum | One of the five supported kinds. |
Course Group (catalog) level
| Field | Type | Description |
|---|---|---|
description | string | Catalog/detail description. Max 5,000 chars. |
tagIds | [ID] | Tag UUIDs for categorization. Max 10. Tags must already exist on the company (GET https://example.thoughtindustries.com/incoming/v2/tags). |
customFields | JSON | {"slug": "value" or ["v1","v2"]}. Keys are strings; values are string, string[], or null. |
asset | URL | Catalog thumbnail (recommended 800×385, PNG/JPEG/GIF). Downloaded → S3 via background job. |
assetAltText | string | Alt text for asset. Defaults to "" when asset is provided. |
detailAsset | URL | Detail-page image (recommended 800×450). Downloaded → S3 via background job. |
detailAssetAltText | string | Alt text for detailAsset. |
ribbon | slug | Ribbon badge slug (e.g. new). Must exist in the company's ribbon config. |
metaTitle | string | SEO meta title. Max 200 chars, HTML stripped. |
metaDescription | string | SEO meta description. Max 500 chars, HTML stripped. |
contentType | string | Content-type label (e.g. "Training Module"). Auto-derived from kind if omitted; must exist in the company's config. |
isbn | string | ISBN identifier. |
relatedCourseGroupIds | [ID] | Related course-group UUIDs (must exist on company). |
publishDate | date | Course-group catalog date used for search/display ordering. |
Course (session) level
| Field | Type | Description |
|---|---|---|
priceInCents | int | Price in cents (e.g. 9900 = $99.00). Max 99,999,999. Cannot be negative. |
freeWithRegistration | boolean | Free for all registered users (default false). |
purchasable | boolean | Enable purchasing (default false). |
status | enum | Create accepts draft or published; omit → draft. With futurePublishDate, status becomes pending. (Full enum in the appendix.) |
futurePublishDate | date | Auto-publish the session at this date. Implies pending. Cannot combine with status: published (returns 400). |
sku | string | SKU identifier. |
discussionsEnabled | boolean | Enable course discussions. |
availableToPublic | boolean | Available without login. |
enrollmentLimit | int | Max enrollments. Setting it enables seat limits (seatsLimitEnabled). |
enrollmentStartDate | date | When enrollment opens (defaults to now if omitted). |
enrollmentEndDate | date | When enrollment closes. |
courseStartDate | date | Content availability start (defaults to now if omitted). |
courseEndDate | date | Content availability end. |
gracePeriodEndDate | date | Extended access after end. |
sessionCustomFields | JSON | Session-level custom fields (separate from customFields). |
forceLinearProgress | boolean | Force completion of pages in order (default false). |
showProgress | boolean | Show progress indicator (default true). |
prerequisiteCourseIds | [ID] | Prerequisite course UUIDs (must exist). |
prerequisiteLearningPathIds | [ID] | Prerequisite learning-path UUIDs (must exist). |
Structure / kind-specific
| Field | Type | Applies to | Description |
|---|---|---|---|
sections | [CourseSection] | courseGroup | Section objects (see below). |
topics | [CourseTopic] | microCourse | Top-level topics (see below). |
articleVariant | ArticlePageVariant | article | Article content (see below). |
scormUrl | URL | shareableContentObject | SCORM package URL. |
width, height | int | shareableContentObject | Display dimensions (pixels). |
embeddedEnabled | boolean | shareableContentObject | Enable embedded display. |
resetSessionAfterComplete | boolean | shareableContentObject | Reset SCORM session on completion. |
videoUrl | URL | video | External video URL → uploaded to Wistia (background job). |
videoAsset | ID | video | Existing Wistia media ID or Synthesia UUID. Mutually exclusive with videoUrl. |
assetType | enum | video | wistia (default) or synthesia. Cannot be combined with videoUrl. Required for Synthesia. |
preAsset | ID | video | Pre-roll video Wistia ID. |
postAsset | ID | video | Post-roll video Wistia ID. |
posterImageAsset | URL | video | Poster image URL. |
For
kind: "video", the video fields are set at the course (top) level, not inside atopicsarray. The platform auto-creates a single topic.
The nested structure objects:
CourseSection — title (string, required), lessons ([CourseLesson], required), releaseDate (date, optional — when the section is released).
CourseLesson — title (string, required), openType (enum, required), topics ([CourseTopic], required). openType values (CourseLessonOpenType): studentsOnly (login + enrollment), open (free preview, public), emailCaptureOpen (email required).
CourseTopic — full field list:
| Field | Type | Description |
|---|---|---|
title | string | Required. Topic title. |
type | enum | Required. A TopicType value (see above). |
body | string | HTML content (for text/article/embed). |
languages | [ArticlePageVariant] | Multi-language content variants. |
preTextBlock / postTextBlock | string | Text before/after the topic content. |
width / height | int | Display dimensions (pixels). |
embeddedEnabled | boolean | Enable embedded display. |
fullscreenEmbed | boolean | Enable fullscreen embed. |
preventProgression | boolean | Block advancing until complete. |
resetSessionAfterComplete | boolean | Reset session after completion. |
courseCompletionCriteria | object | Completion criteria for this topic (see the appendix). |
caption | string | Caption text. |
searchDisabled | boolean | Exclude from search. |
printDisabled | boolean | Disable printing. |
fileDownloadDisabled | boolean | Disable file download. |
scormUrl | URL | SCORM/xAPI package (shareableContentObject/xApiObject). |
scoTitle | string | SCORM title. |
objectType | string | SCORM standard type. |
pdfUrl | URL | PDF file URL (pdfViewer). |
videoUrl | URL | External video URL → Wistia (video). |
videoAsset | ID | Existing Wistia/Synthesia ID (video). Mutually exclusive with videoUrl. |
assetType | enum | wistia (default) or synthesia. |
preAsset / postAsset | ID | Pre-/post-roll Wistia IDs. |
posterImageAsset | URL | Poster image URL. |
ArticlePageVariant (used by articleVariant and topic languages[]): language (e.g. "en"), label (e.g. "English"), title, subtitle, body (HTML), copyright, externalUrl, externalUrlCallToAction.
Step 3 — Full example
A production-ready courseGroup request using catalog + session fields and multiple topic types.
curl -X "POST" "https://example.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",
"description": "A comprehensive intro to marketing fundamentals.",
"sku": "MKT-101",
"status": "published",
"priceInCents": 9900,
"purchasable": true,
"discussionsEnabled": true,
"availableToPublic": false,
"enrollmentLimit": 500,
"tagIds": ["c1d2e3f4-a5b6-7890-abcd-ef1234567890"],
"metaTitle": "Intro to Marketing | Acme",
"metaDescription": "Learn the 4 Ps and more.",
"asset": "https://example.com/img/mkt-101-thumb.png",
"assetAltText": "Marketing course thumbnail",
"forceLinearProgress": true,
"sections": [
{
"title": "Getting Started",
"lessons": [
{
"title": "Welcome",
"openType": "open",
"topics": [
{ "title": "Course Overview", "type": "text", "body": "<p>Welcome to the course!</p>" },
{ "title": "Embedded Intro", "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", "type": "article", "body": "<h2>Product, Price, Place, Promotion</h2>" },
{ "title": "Knowledge Check", "type": "quiz" }
]
}
]
}
]
}
]
}'Step 4 — Success response
A successful create returns an object containing the created course and course-group IDs. (There is no slug/createdAt envelope — the response is purely the ID map.)
{
"courseIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"],
"courseGroupIds": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"]
}When the request includes files that must be fetched and processed (SCORM, xAPI, PDF, videoUrl uploads, or images), the response also includes a backgroundJob object — see Background jobs:
{
"courseIds": ["b2c3d4e5-f6a7-8901-bcde-f23456789012"],
"courseGroupIds": ["c3d4e5f6-a7b8-9012-cdef-345678901234"],
"backgroundJob": {
"id": "job-uuid-here",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [
{ "topicId": "topic-uuid-here", "courseId": "b2c3d4e5-f6a7-8901-bcde-f23456789012", "contentType": "scorm" }
]
}
}Limits (verified in controller + resolver)
| Limit | Value | Source |
|---|---|---|
| Max courses per request | 100 | contentv2.ts + APICreateCourses |
| Max children per course (sections + lessons + topics combined) | 25 | contentv2.ts + APICreateCourses |
Empty courseAttributes | rejected (No items provided, 400) | both layers |
Examples by course kind
microCourse — topics at the top level
curl -X "POST" "https://example.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 them clear and concise.</p>" },
{ "title": "Professional Tone", "type": "text", "body": "<p>Stay professional.</p>" },
{ "title": "Quiz", "type": "quiz" }
]
}
]
}'article — article/blog content
curl -X "POST" "https://example.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": {
"language": "en",
"label": "English",
"title": "Working from Home",
"body": "<h1>Working from Home</h1><p>Here are the best practices...</p>"
}
}
]
}'shareableContentObject — standalone SCORM (SCORM upload queued → response includes backgroundJob)
curl -X "POST" "https://example.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"
}
]
}'video — standalone video (upload from URL)
curl -X "POST" "https://example.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"
}
]
}'video — standalone video (reuse existing Wistia media; no upload job)
curl -X "POST" "https://example.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"
}
]
}'Examples by topic (page) type
These topics are placed inside a courseGroup lesson (or a microCourse topics[] array).
text — { "title": "...", "type": "text", "body": "<p>HTML</p>" }
article (multi-language):
{ "title": "Overview", "type": "article",
"languages": [
{ "language": "en", "label": "English", "title": "Overview", "body": "<p>English body</p>" },
{ "language": "es", "label": "Español", "title": "Resumen", "body": "<p>Cuerpo en español</p>" }
] }video (upload from URL): { "title": "Lesson Video", "type": "video", "videoUrl": "https://example.com/lesson.mp4" }
video (existing Wistia media): { "title": "Lesson Video", "type": "video", "videoAsset": "abc123wistia" }
shareableContentObject (SCORM topic):
{ "title": "SCORM Module", "type": "shareableContentObject", "scormUrl": "https://example.com/pkg.zip", "width": 1024, "height": 768 }xApiObject (xAPI topic — the supported way to add xAPI):
{ "title": "xAPI Module", "type": "xApiObject", "scormUrl": "https://example.com/xapi-pkg.zip" }pdfViewer: { "title": "Course Materials", "type": "pdfViewer", "pdfUrl": "https://example.com/materials.pdf" }
embed:
{ "title": "Embedded Form", "type": "embed", "body": "<iframe src=\"https://forms.example.com/x\" width=\"640\" height=\"480\"></iframe>" }quiz / test / survey / assignment (shells — add questions/config in UI): { "title": "Final Assessment", "type": "test" }
Topic with completion criteria (e.g. require 80% of pages viewed):
{ "title": "Reading", "type": "text", "body": "<p>...</p>",
"courseCompletionCriteria": { "type": "coursePercentViewed", "coursePercentViewed": 80 } }Validation rules
The API enforces several validation rules. Understanding these helps you build resilient integrations that handle edge cases gracefully. Validation failures return HTTP 400 with an errors array (see Error responses). Verified rules and messages:
| Rule | Trigger | Message / behavior |
|---|---|---|
| Items required | empty courseAttributes | No items provided |
title required | missing/empty title | course must have a non-empty title |
kind required & valid | kind not in the 5 supported | Invalid course kinds: <kind>. Supported kinds are: article, video, courseGroup, microCourse, shareableContentObject |
| Max courses (create) | > 100 courses | Cannot create more than 100 courses at a time |
| Max items (update) | combined entities > 100 | Cannot update more than 100 items at a time |
| Max children | sections + lessons + topics > 25 (per course) | Course at index N exceeds maximum of 25 children ... Found X children. |
| Invalid status | status not in Status enum | Invalid status '<x>'. Valid values are: draft, authoring, published, loginRestriction, archived, pending, deleted |
futurePublishDate + published | both set | Cannot set futurePublishDate when status is published. |
| Description length | > 5,000 chars | Description exceeds maximum length of 5,000 characters |
| Too many tags | > 10 tagIds | Cannot assign more than 10 tags per course... |
| Unknown tag | tag id missing / wrong company | Some tag IDs were not found / Some tags do not belong to this company |
metaTitle length | > 200 chars | metaTitle exceeds maximum length of 200 characters |
metaDescription length | > 500 chars | metaDescription exceeds maximum length of 500 characters |
| Price too large | priceInCents > 99,999,999 | priceInCents exceeds maximum allowed value of 99999999 ($999,999.99) |
customFields shape | not an object, or non-string values | customFields must be a JSON object... / customFields values must be string, string array, or null... |
| Video: both sources | videoUrl and videoAsset | Cannot provide both 'videoUrl' and 'videoAsset'... |
| Video: type mismatch | video fields on a non-video topic type | Cannot use video fields (videoUrl, videoAsset) with topic type '<type>'... |
| Video: assetType + URL | assetType set with videoUrl | Cannot provide 'assetType' when using 'videoUrl'... set to 'wistia' after upload. |
| Update: topic not found | id not owned by company | Topics not found: <ids> |
| Update: missing parent | new topic without lessonId/type | lessonId required for new topics... / type required for new topics... |
| Update: missing parent | new section/lesson/course without parent | courseId required... / sectionId required... / courseGroupId required... |
| Update: invalid parent | parent id not owned | Invalid lessonId/sectionId/courseId/courseGroupId: <id> |
| Too many pending jobs | content uploads can't be queued | Too many pending background jobs... |
Notes on behavior:
statusdefaults todraftwhen omitted, so the course is not visible to learners until published.descriptionaccepts basic HTML; SEOmetaTitle/metaDescriptionhave HTML stripped.tagIdsmust already exist on the company (max 10) — unlike some systems, tags are not auto-created here; look them up withGET https://example.thoughtindustries.com/incoming/v2/tags.
Error responses
When validation fails, the API returns HTTP 400 with an errors array of descriptive strings. For a single course the array has one message; for multiple courses each message is prefixed with Course at index N:.
{
"errors": [
"Invalid course kinds: xApiObject. Supported kinds are: article, video, courseGroup, microCourse, shareableContentObject"
]
}Multi-course example (index-prefixed):
{ "errors": ["Course at index 0: Invalid status 'foo'. Valid values are: draft, authoring, published, loginRestriction, archived, pending, deleted"] }The error envelope is always
{ "errors": [ ... ] }(a list of strings). Inspect each message to map back to the offending field/course index.
Update courses
After creating a course you'll often want to update it — publish it, add tags, replace SCORM, or append sections/lessons/topics.
https://example.thoughtindustries.com/incoming/v2/content/course/updateThe update endpoint supports partial updates (only the fields you send are changed) and can also create new sections, lessons, topics, courses, and course groups by omitting the id field.
Unlike create, the body's courseAttributes is an object grouped by entity type:
{
"courseAttributes": {
"courseGroups": [ ],
"courses": [ ],
"sections": [ ],
"lessons": [ ],
"topics": [ ]
}
}Semantics (verified in APIUpdateCourses, gql/course.ts)
- Update vs. create per entity: within each array, items with an
idare updated; items without anidare created (the relevant parent ID is then required — see table). - Partial update: only provided fields are written. For nullable fields, sending
nullclears the value; omitting a field leaves it unchanged (e.g.description,tagIds,customFields,asset,relatedCourseGroupIds). - Total items limit: the combined count across
sections + courseGroups + courses + lessons + topicsmust be ≤ 100 (else 400). Empty →No items provided(400). - Ownership: every referenced
idmust belong to your company, or the request fails (e.g.Topics not found: <ids>,Some courses not found or unauthorized). - Topic type change: if you change a topic's
type, incompatible fields are reset to defaults ("clean slate"), then your provided values are applied. - Archiving: set
archived: trueon a course group to archive it (this also sets its courses toarchived);archived: falserestores them todraft.archivedcannot be set on create.
Parent-ID requirements when creating via update
| Creating a new… | Array | Required parent field |
|---|---|---|
| Course group | courseGroups[] | none (top-level) |
| Course (session) | courses[] | courseGroupId |
| Section | sections[] | courseId |
| Lesson | lessons[] | sectionId |
| Topic | topics[] | lessonId + type |
Update field references
UpdateCourseGroup: id, title, slug, description, tagIds, customFields, asset, assetAltText, detailAsset, detailAssetAltText, ribbon, metaTitle, metaDescription, archived, relatedCourseGroupIds.
UpdateCourse: id, courseGroupId, title, kind, priceInCents, freeWithRegistration, purchasable, status, customFields, forceLinearProgress, showProgress, prerequisiteCourseIds, prerequisiteLearningPathIds.
UpdateCourseSection: id, courseId, title, releaseDate, displayOrder.
UpdateCourseLesson: id, sectionId, title, openType.
UpdateCourseTopic: id, lessonId, title, type, body, languages, preTextBlock, postTextBlock, width, height, embeddedEnabled, fullscreenEmbed, preventProgression, resetSessionAfterComplete, caption, searchDisabled, printDisabled, fileDownloadDisabled, scormUrl, scoTitle, objectType, restartProgress (restart learner progress when replacing SCORM), pdfUrl, videoUrl, videoAsset, assetType, preAsset, postAsset, posterImageAsset.
Update examples
Publish a course group and add a tag (response: true):
curl -X "PUT" "https://example.thoughtindustries.com/incoming/v2/content/course/update" \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-d '{ "courseAttributes": { "courseGroups": [ { "id": "1e7a0ab5-9e13-44d5-90bf-e7c61e7448f8", "title": "Updated Course Title" } ], "courses": [ { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "published" } ] } }'Update topic body:
curl -X "PUT" "https://example.thoughtindustries.com/incoming/v2/content/course/update" \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-d '{ "courseAttributes": { "topics": [ { "id": "1366501c-3740-4a30-9464-1a2f111499ee", "body": "<p>Updated content</p>" } ] } }'Add a new section to an existing course:
curl -X "PUT" "https://example.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 } ] } }'Add a new PDF topic to an existing lesson:
curl -X "PUT" "https://example.thoughtindustries.com/incoming/v2/content/course/update" \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-d '{ "courseAttributes": { "topics": [ { "lessonId": "93e6076b-ba84-4012-84ff-73c0367006e0", "title": "Materials PDF", "type": "pdfViewer", "pdfUrl": "https://example.com/materials.pdf" } ] } }'Replace SCORM on an existing topic (and restart progress):
curl -X "PUT" "https://example.thoughtindustries.com/incoming/v2/content/course/update" \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-d '{ "courseAttributes": { "topics": [ { "id": "fdbec9af-e134-4c42-81c4-05a056dd40ca", "scormUrl": "https://example.com/updated-module.zip", "restartProgress": true } ] } }'Update response with a queued upload:
{
"success": true,
"backgroundJob": {
"id": "job-uuid-here",
"type": "contentBulkUpload",
"status": "queued",
"contentUploads": [ { "topicId": "fdbec9af-e134-4c42-81c4-05a056dd40ca", "contentType": "scorm" } ]
}
}Response shape: when no file upload is queued, update returns the bare boolean
true. When an upload (SCORM/xAPI/PDF/video/image) is queued, it returns the{ "success": true, "backgroundJob": {...} }object.
Related course-management endpoints
All paths are prefixed with https://example.thoughtindustries.com/incoming/v2. Verified against the route map in incoming/index.ts and the controllers.
Create / update
| Method | Path | Purpose |
|---|---|---|
| POST | /content/course/create | Create one or more full courses (this guide's primary endpoint). |
| PUT | /content/course/update | Update/partial-update or create sub-entities of existing courses. |
Read — content & catalog
| Method | Path | Purpose |
|---|---|---|
| GET | /content | Search/list content items (filter by types[], query, sort, paginated by cursor/perPage). |
| GET | /content/{type}/{id} | Get a single content item's body (id, title, slug, body). type is a ContentSearchType (e.g. courses, courseGroups, learningPaths). |
| GET | /fullContent/{type}/{id} | Get full indexed content. Only courses and learningPaths types supported. |
| GET | /content/{type}/{id}.xlf | Download XLIFF translation file for a content item. |
| PUT | /content/{type}/{id}.xlf | Upload an XLIFF translation file (Content-Type: text/xml). |
Read — course groups
| Method | Path | Purpose |
|---|---|---|
| GET | /courseGroups | List course groups (filter by kind, isTemplate, archived; paginated). |
| GET | /courseGroups/{id} | Get a course group by ID. |
| GET | /courseGroups/slug/{slug} | Get a course group by slug. |
| GET | /courseGroups/{id}/courses | List the courses (sessions) in a course group (activeOnly optional). |
| GET | /courseGroups/{id}/displayCourse | Get the course group's display (default) course. |
Read — course structure
| Method | Path | Purpose |
|---|---|---|
| GET | /courses/{courseId}/structure | Full hierarchical structure: sections → lessons → topics + completion criteria. |
| GET | /courses/{courseId}/sections | List sections in a course (paginated). |
| GET | /courses/{courseId}/lessons | List all lessons in a course, flattened (paginated). |
| GET | /courses/{courseId}/completionCriteria | List the course's completion criteria. |
| GET | /sections/{sectionId}/lessons | List lessons in a section (paginated). |
| GET | /sections/{id} | Get a single section. |
| GET | /lessons/{id} | Get a single lesson. |
Read — topics
| Method | Path | Purpose |
|---|---|---|
| GET | /topics/course/{courseId} | List topics in a course (paginated). |
| GET | /topics/lesson/{lessonId} | List topics in a lesson (paginated). |
| GET | /topics/{id} | Get a single topic. |
Publish / archive / delete
| Method | Path | Purpose |
|---|---|---|
| PUT | /content/{id}/releaseContent | Publish or unpublish. Body: { "kind": "course" | "learningPath", "action": "release" | "unrelease" }. Returns { id, status: "published" | "draft" }. |
| PUT | /content/course/update | Archive a course group via { "courseAttributes": { "courseGroups": [ { "id": "...", "archived": true } ] } }. |
There is no dedicated REST endpoint to hard-delete a course. The route map exposes
DELETEonly for clients, licenses, and users — not courses. To remove a course from circulation, archive it via the update endpoint (archived: true), or set itsstatusaccordingly. (Verified: noroute.del('/v2/content...')or course delete route exists inincoming/index.ts.)
Bulk content operations (course-adjacent)
| Method | Path | Purpose |
|---|---|---|
| POST | /content/bulkUpdatePrices | Bulk update prices for courses/learning paths (≤ 1000 items). |
| POST | /content/completion-criteria/bulk | Bulk create completion criteria. |
| POST | /content/completion-criteria/bulk/update | Bulk update completion criteria. |
| POST | /content/certificate-templates/bulk | Bulk create certificate templates. |
| POST | /content/certificate-templates/bulk/update | Bulk update certificate templates. |
Bulk import — webinar / in-person event (the way to create those "kinds")
| Method | Path | Purpose |
|---|---|---|
| POST | /content/session/webinar/bulk | Bulk import webinar sessions (from an asset path). |
| POST | /content/session/inPersonEvent/bulk | Bulk import in-person-event sessions. |
| POST | /content/courseGroup/webinar/bulk | Bulk import webinar course groups. |
| POST | /content/courseGroup/inPersonEvent/bulk | Bulk import in-person-event course groups. |
These run as background jobs and accept a bulkImportAssetPath URL pointing to the import file.
Supporting
| Method | Path | Purpose |
|---|---|---|
| GET | /jobs/{id} | Poll the status of a background job (e.g. a content upload queued by create/update). |
| GET | /tags | List tags (to obtain valid tagIds). |
| GET | /learningPathMilestones | List learning-path milestones and their courses (by learningPathId/learningPathSku). |
Background jobs
When a create/update request includes files that must be fetched and processed — SCORM, xAPI, PDF, video uploads via videoUrl, or images (asset/detailAsset) — the API:
- Creates the course/topic records immediately, then
- Queues a
contentBulkUploadbackground job for the file work, and - Returns a
backgroundJobobject (id,type,status,contentUploads[]) in the response.
Track completion by polling:
GET /v2/jobs/{id}Notes:
- Providing
videoAsset(an existing Wistia/Synthesia ID) does not queue a job; onlyvideoUrl(upload from URL) does. - If the company already has too many pending jobs, the request errors with
Too many pending background jobs...— the course may be created but files won't be queued; retry the file portion via the update endpoint later. - For Synthesia videos, the platform automatically clears any
videoPercentViewedcompletion criteria (Synthesia progress can't be tracked that way).
Common integration patterns
Practical patterns for wiring course/create and course/update into a content pipeline. Each uses only verified fields/endpoints.
CMS sync
Map your CMS records to courses using sku (or customFields). On sync, look up existing content with GET https://example.thoughtindustries.com/incoming/v2/content (filter by query/types[]); create new courses via course/create and apply changes to existing ones via course/update (partial updates leave untouched fields unchanged).
Client onboarding
Provision a template set of courses for each new client. Group them with tagIds, and store client-specific metadata in customFields (catalog level) or sessionCustomFields (session level). Use relatedCourseGroupIds to cross-link related catalog items.
Content pipeline
Create courses as draft, attach SCORM/PDF/video and additional sections/lessons/topics over time via course/update, then publish programmatically — set status: "published" (or call PUT https://example.thoughtindustries.com/incoming/v2/content/{id}/releaseContent).
Bulk import
Create up to 100 courses per request (≤ 25 children each) in a single course/create call. For webinar (VILT) and in-person-event (ILT) course groups, use the dedicated bulk-import endpoints (see Related course-management endpoints). These routes carry no throttle middleware, but batch sensibly and handle errors per index (multi-course errors are prefixed with Course at index N:).
Appendix: verified enum values
CourseGroupKind (full enum): microCourse, courseGroup, article, webinar, video, shareableContentObject, xApiObject, inPersonEvent, webinarCourse, inPersonEventCourse.
→ Accepted by course/create: only article, video, courseGroup, microCourse, shareableContentObject.
Status: draft, authoring, published, loginRestriction, archived, pending, deleted.
→ Create accepts draft / published (omit → draft; futurePublishDate → pending). Other values are valid enum members and validated, but draft/published are the meaningful create inputs.
CourseLessonOpenType: studentsOnly, open, emailCaptureOpen.
VideoProvider (assetType): wistia, synthesia.
TopicType: ad, article, image, slideshow, presentation, quiz, test, survey, text, video, recipe, assignment, listRoll, lti, tally, flipCardSet, notebook, highlightZoneSet, shareableContentObject, xApiObject, audio, matchPairSet, highlightZoneQuiz, socialShareCardSet, surveyGizmo, discussionBoard, embed, workbook, pdfViewer, inPersonEvent, meetingInfo, bongo. (See the supported topic types section for which can be populated via API.)
CourseCompletionCriteriaType (for topic courseCompletionCriteria.type):
| Value | Meaning | Companion field |
|---|---|---|
coursePercentViewed | Percentage of pages viewed | coursePercentViewed (1–100) |
articlePercentViewed | Percentage of a page viewed | articlePercentViewed (1–100) |
videoPercentViewed | Percentage of video viewed | videoPercentViewed (1–100) + videoTopicId |
articleTimeViewedInSeconds | Time spent viewing a page | articleTimeViewedInSeconds |
courseTopicViewed | Specific page viewed | topic id |
courseAssessmentPassed | Assessment passed | topic id |
courseAssignmentComplete | Assignment completed | topic id |
courseMeetingAttended | Meeting attended | topic id |
scormComplete | SCORM complete | topic id |
xApiComplete | xAPI complete | topic id |
surveyGizmoComplete | Survey Gizmo complete | topic id |
bongoAssignmentCompleted | Bongo assignment completed | topic id |
proctoredTopicComplete | Proctored topic complete | topic id |
videoTopicId | Video topic viewed | topic id |
ContentSearchType (for GET /content/{type}/{id}): courseGroups, courses, bundles, discountGroups, pickableGroups, products, learningPaths.
Flagged as unverified / not confirmable from source
- Population via API of "shell-only" topic types (
image,slideshow,presentation,audio,quiz/test/surveyquestions,assignmentconfig, etc.): no dedicated content fields exist on the topic input types, so these can only be created as placeholder pages and configured in the UI. Theaudiopage type in particular has no API field to set its asset. - Exact rate-limit numbers for these specific routes: the create/update routes carry no throttle middleware in code; any platform-wide limits are not defined in the route map.
Statuscreate acceptance of values beyonddraft/published/pending(e.g.authoring,loginRestriction): these are valid enum members and pass status validation, butdraft/publishedare the documented/meaningful create inputs; behavior of the others on create is not separately verified.
Next steps
- Create Courses reference —
/docs/rest/courses/create - Update Courses reference —
/docs/rest/courses/update - Get Course Structure —
/docs/rest/courses/structure - GraphQL API — query and mutate content with GraphQL (
/docs/graphql) - Webhooks — get notified when courses are created or updated (
/docs/webhooks) - API Fundamentals — Thought Industries Academy — a 5-minute primer on API fundamentals.