Skip to main content
https://.thoughtindustries.com

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/v2 as the instance base-URL placeholder, which expands to your instance's /incoming/v2 prefix (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.new to create, courses.edit to update). Find your key in the platform under Settings > API Access. All requests use the incoming/v2 endpoint 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.

LevelPlatform termContainsNotes
Course GroupCatalog itemone or more Courses (sessions)The catalog-visible object. Holds title, description, asset/thumbnail, tags, SEO meta, ribbon, content type.
CourseSessionone or more SectionsThe enrollable session. Holds pricing, dates, enrollment limits, prerequisites, progress settings.
SectionModuleone or more LessonsHas a title and optional release date.
LessonUnitone or more TopicsHas a title and an openType (access level).
TopicPage / learning objectthe actual contentArticle, video, quiz, SCORM, etc. — see supported topic types below.

Verification: This is confirmed in the schema input types CourseAttributesCourseSectionCourseLessonCourseTopic (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 courses to 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. Bearer is the documented scheme.
  • Alternatively the key may be supplied as secretKey in 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):

ActionEndpointPermission
Create coursesPOST https://example.thoughtindustries.com/incoming/v2/content/course/createcourses.new
Update coursesPUT https://example.thoughtindustries.com/incoming/v2/content/course/updatecourses.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"
]
kindWhat it createsStructure you supply
courseGroupStandard multi-section coursesections[]lessons[]topics[]
microCourseLightweight course, topics at the top leveltopics[] (flattened into a single section/lesson)
articleArticle/blog-style contentarticleVariant (one article topic auto-created)
videoStandalone video learning objectcourse-level videoUrl or videoAsset (one topic auto-created)
shareableContentObjectStandalone SCORM packagescormUrl, 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:

  • xApiObject is not a valid course kind. Sending it returns a 400. xAPI content is added as a topic (type: "xApiObject" with a scormUrl) inside a course of one of the five supported kinds. (Note: the create resolver does contain a code path referencing a standalone xApiObject course 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, not course/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

typePopulate withNotes
textbody (HTML)Rich text page.
articlebody and/or languages[] (ArticlePageVariant)Article page; supports multi-language variants.
videovideoUrl (upload) or videoAsset (existing Wistia/Synthesia)See video rules in Validation rules below.
shareableContentObjectscormUrl (+ optional width, height, embeddedEnabled)SCORM package; queued to a background job.
xApiObjectscormUrlxAPI package; queued to a background job.
pdfViewerpdfUrlPDF; queued to a background job.
embedbody (e.g. an <iframe>)Embedded third-party content.
discussionBoardDiscussion forum page (created as a functional page).

Created as a shell (page exists; configure remaining content/questions in the UI)

typeWhy a shell
quiz, test, survey, workbookQuestions are added via the authoring UI after creation.
assignment, bongoAssignment configuration is done in the UI.
audioNo 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, inPersonEventThese 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

POSThttps://example.thoughtindustries.com/incoming/v2/content/course/create

The 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

FieldTypeNotes
titlestringCourse title.
kindenumOne of the five supported kinds.

Course Group (catalog) level

FieldTypeDescription
descriptionstringCatalog/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).
customFieldsJSON{"slug": "value" or ["v1","v2"]}. Keys are strings; values are string, string[], or null.
assetURLCatalog thumbnail (recommended 800×385, PNG/JPEG/GIF). Downloaded → S3 via background job.
assetAltTextstringAlt text for asset. Defaults to "" when asset is provided.
detailAssetURLDetail-page image (recommended 800×450). Downloaded → S3 via background job.
detailAssetAltTextstringAlt text for detailAsset.
ribbonslugRibbon badge slug (e.g. new). Must exist in the company's ribbon config.
metaTitlestringSEO meta title. Max 200 chars, HTML stripped.
metaDescriptionstringSEO meta description. Max 500 chars, HTML stripped.
contentTypestringContent-type label (e.g. "Training Module"). Auto-derived from kind if omitted; must exist in the company's config.
isbnstringISBN identifier.
relatedCourseGroupIds[ID]Related course-group UUIDs (must exist on company).
publishDatedateCourse-group catalog date used for search/display ordering.

Course (session) level

FieldTypeDescription
priceInCentsintPrice in cents (e.g. 9900 = $99.00). Max 99,999,999. Cannot be negative.
freeWithRegistrationbooleanFree for all registered users (default false).
purchasablebooleanEnable purchasing (default false).
statusenumCreate accepts draft or published; omit → draft. With futurePublishDate, status becomes pending. (Full enum in the appendix.)
futurePublishDatedateAuto-publish the session at this date. Implies pending. Cannot combine with status: published (returns 400).
skustringSKU identifier.
discussionsEnabledbooleanEnable course discussions.
availableToPublicbooleanAvailable without login.
enrollmentLimitintMax enrollments. Setting it enables seat limits (seatsLimitEnabled).
enrollmentStartDatedateWhen enrollment opens (defaults to now if omitted).
enrollmentEndDatedateWhen enrollment closes.
courseStartDatedateContent availability start (defaults to now if omitted).
courseEndDatedateContent availability end.
gracePeriodEndDatedateExtended access after end.
sessionCustomFieldsJSONSession-level custom fields (separate from customFields).
forceLinearProgressbooleanForce completion of pages in order (default false).
showProgressbooleanShow progress indicator (default true).
prerequisiteCourseIds[ID]Prerequisite course UUIDs (must exist).
prerequisiteLearningPathIds[ID]Prerequisite learning-path UUIDs (must exist).

Structure / kind-specific

FieldTypeApplies toDescription
sections[CourseSection]courseGroupSection objects (see below).
topics[CourseTopic]microCourseTop-level topics (see below).
articleVariantArticlePageVariantarticleArticle content (see below).
scormUrlURLshareableContentObjectSCORM package URL.
width, heightintshareableContentObjectDisplay dimensions (pixels).
embeddedEnabledbooleanshareableContentObjectEnable embedded display.
resetSessionAfterCompletebooleanshareableContentObjectReset SCORM session on completion.
videoUrlURLvideoExternal video URL → uploaded to Wistia (background job).
videoAssetIDvideoExisting Wistia media ID or Synthesia UUID. Mutually exclusive with videoUrl.
assetTypeenumvideowistia (default) or synthesia. Cannot be combined with videoUrl. Required for Synthesia.
preAssetIDvideoPre-roll video Wistia ID.
postAssetIDvideoPost-roll video Wistia ID.
posterImageAssetURLvideoPoster image URL.

For kind: "video", the video fields are set at the course (top) level, not inside a topics array. The platform auto-creates a single topic.

The nested structure objects:

CourseSectiontitle (string, required), lessons ([CourseLesson], required), releaseDate (date, optional — when the section is released).

CourseLessontitle (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:

FieldTypeDescription
titlestringRequired. Topic title.
typeenumRequired. A TopicType value (see above).
bodystringHTML content (for text/article/embed).
languages[ArticlePageVariant]Multi-language content variants.
preTextBlock / postTextBlockstringText before/after the topic content.
width / heightintDisplay dimensions (pixels).
embeddedEnabledbooleanEnable embedded display.
fullscreenEmbedbooleanEnable fullscreen embed.
preventProgressionbooleanBlock advancing until complete.
resetSessionAfterCompletebooleanReset session after completion.
courseCompletionCriteriaobjectCompletion criteria for this topic (see the appendix).
captionstringCaption text.
searchDisabledbooleanExclude from search.
printDisabledbooleanDisable printing.
fileDownloadDisabledbooleanDisable file download.
scormUrlURLSCORM/xAPI package (shareableContentObject/xApiObject).
scoTitlestringSCORM title.
objectTypestringSCORM standard type.
pdfUrlURLPDF file URL (pdfViewer).
videoUrlURLExternal video URL → Wistia (video).
videoAssetIDExisting Wistia/Synthesia ID (video). Mutually exclusive with videoUrl.
assetTypeenumwistia (default) or synthesia.
preAsset / postAssetIDPre-/post-roll Wistia IDs.
posterImageAssetURLPoster 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)

LimitValueSource
Max courses per request100contentv2.ts + APICreateCourses
Max children per course (sections + lessons + topics combined)25contentv2.ts + APICreateCourses
Empty courseAttributesrejected (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:

RuleTriggerMessage / behavior
Items requiredempty courseAttributesNo items provided
title requiredmissing/empty titlecourse must have a non-empty title
kind required & validkind not in the 5 supportedInvalid course kinds: <kind>. Supported kinds are: article, video, courseGroup, microCourse, shareableContentObject
Max courses (create)> 100 coursesCannot create more than 100 courses at a time
Max items (update)combined entities > 100Cannot update more than 100 items at a time
Max childrensections + lessons + topics > 25 (per course)Course at index N exceeds maximum of 25 children ... Found X children.
Invalid statusstatus not in Status enumInvalid status '<x>'. Valid values are: draft, authoring, published, loginRestriction, archived, pending, deleted
futurePublishDate + publishedboth setCannot set futurePublishDate when status is published.
Description length> 5,000 charsDescription exceeds maximum length of 5,000 characters
Too many tags> 10 tagIdsCannot assign more than 10 tags per course...
Unknown tagtag id missing / wrong companySome tag IDs were not found / Some tags do not belong to this company
metaTitle length> 200 charsmetaTitle exceeds maximum length of 200 characters
metaDescription length> 500 charsmetaDescription exceeds maximum length of 500 characters
Price too largepriceInCents > 99,999,999priceInCents exceeds maximum allowed value of 99999999 ($999,999.99)
customFields shapenot an object, or non-string valuescustomFields must be a JSON object... / customFields values must be string, string array, or null...
Video: both sourcesvideoUrl and videoAssetCannot provide both 'videoUrl' and 'videoAsset'...
Video: type mismatchvideo fields on a non-video topic typeCannot use video fields (videoUrl, videoAsset) with topic type '<type>'...
Video: assetType + URLassetType set with videoUrlCannot provide 'assetType' when using 'videoUrl'... set to 'wistia' after upload.
Update: topic not foundid not owned by companyTopics not found: <ids>
Update: missing parentnew topic without lessonId/typelessonId required for new topics... / type required for new topics...
Update: missing parentnew section/lesson/course without parentcourseId required... / sectionId required... / courseGroupId required...
Update: invalid parentparent id not ownedInvalid lessonId/sectionId/courseId/courseGroupId: <id>
Too many pending jobscontent uploads can't be queuedToo many pending background jobs...

Notes on behavior:

  • status defaults to draft when omitted, so the course is not visible to learners until published.
  • description accepts basic HTML; SEO metaTitle/metaDescription have HTML stripped.
  • tagIds must already exist on the company (max 10) — unlike some systems, tags are not auto-created here; look them up with GET 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.

PUThttps://example.thoughtindustries.com/incoming/v2/content/course/update

The 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 id are updated; items without an id are created (the relevant parent ID is then required — see table).
  • Partial update: only provided fields are written. For nullable fields, sending null clears 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 + topics must be ≤ 100 (else 400). Empty → No items provided (400).
  • Ownership: every referenced id must 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: true on a course group to archive it (this also sets its courses to archived); archived: false restores them to draft. archived cannot be set on create.

Parent-ID requirements when creating via update

Creating a new…ArrayRequired parent field
Course groupcourseGroups[]none (top-level)
Course (session)courses[]courseGroupId
Sectionsections[]courseId
Lessonlessons[]sectionId
Topictopics[]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.

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

MethodPathPurpose
POST/content/course/createCreate one or more full courses (this guide's primary endpoint).
PUT/content/course/updateUpdate/partial-update or create sub-entities of existing courses.

Read — content & catalog

MethodPathPurpose
GET/contentSearch/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}.xlfDownload XLIFF translation file for a content item.
PUT/content/{type}/{id}.xlfUpload an XLIFF translation file (Content-Type: text/xml).

Read — course groups

MethodPathPurpose
GET/courseGroupsList 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}/coursesList the courses (sessions) in a course group (activeOnly optional).
GET/courseGroups/{id}/displayCourseGet the course group's display (default) course.

Read — course structure

MethodPathPurpose
GET/courses/{courseId}/structureFull hierarchical structure: sections → lessons → topics + completion criteria.
GET/courses/{courseId}/sectionsList sections in a course (paginated).
GET/courses/{courseId}/lessonsList all lessons in a course, flattened (paginated).
GET/courses/{courseId}/completionCriteriaList the course's completion criteria.
GET/sections/{sectionId}/lessonsList lessons in a section (paginated).
GET/sections/{id}Get a single section.
GET/lessons/{id}Get a single lesson.

Read — topics

MethodPathPurpose
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

MethodPathPurpose
PUT/content/{id}/releaseContentPublish or unpublish. Body: { "kind": "course" | "learningPath", "action": "release" | "unrelease" }. Returns { id, status: "published" | "draft" }.
PUT/content/course/updateArchive a course group via { "courseAttributes": { "courseGroups": [ { "id": "...", "archived": true } ] } }.

There is no dedicated REST endpoint to hard-delete a course. The route map exposes DELETE only for clients, licenses, and users — not courses. To remove a course from circulation, archive it via the update endpoint (archived: true), or set its status accordingly. (Verified: no route.del('/v2/content...') or course delete route exists in incoming/index.ts.)

Bulk content operations (course-adjacent)

MethodPathPurpose
POST/content/bulkUpdatePricesBulk update prices for courses/learning paths (≤ 1000 items).
POST/content/completion-criteria/bulkBulk create completion criteria.
POST/content/completion-criteria/bulk/updateBulk update completion criteria.
POST/content/certificate-templates/bulkBulk create certificate templates.
POST/content/certificate-templates/bulk/updateBulk update certificate templates.

Bulk import — webinar / in-person event (the way to create those "kinds")

MethodPathPurpose
POST/content/session/webinar/bulkBulk import webinar sessions (from an asset path).
POST/content/session/inPersonEvent/bulkBulk import in-person-event sessions.
POST/content/courseGroup/webinar/bulkBulk import webinar course groups.
POST/content/courseGroup/inPersonEvent/bulkBulk import in-person-event course groups.

These run as background jobs and accept a bulkImportAssetPath URL pointing to the import file.

Supporting

MethodPathPurpose
GET/jobs/{id}Poll the status of a background job (e.g. a content upload queued by create/update).
GET/tagsList tags (to obtain valid tagIds).
GET/learningPathMilestonesList 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:

  1. Creates the course/topic records immediately, then
  2. Queues a contentBulkUpload background job for the file work, and
  3. Returns a backgroundJob object (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; only videoUrl (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 videoPercentViewed completion 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; futurePublishDatepending). 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):

ValueMeaningCompanion field
coursePercentViewedPercentage of pages viewedcoursePercentViewed (1–100)
articlePercentViewedPercentage of a page viewedarticlePercentViewed (1–100)
videoPercentViewedPercentage of video viewedvideoPercentViewed (1–100) + videoTopicId
articleTimeViewedInSecondsTime spent viewing a pagearticleTimeViewedInSeconds
courseTopicViewedSpecific page viewedtopic id
courseAssessmentPassedAssessment passedtopic id
courseAssignmentCompleteAssignment completedtopic id
courseMeetingAttendedMeeting attendedtopic id
scormCompleteSCORM completetopic id
xApiCompletexAPI completetopic id
surveyGizmoCompleteSurvey Gizmo completetopic id
bongoAssignmentCompletedBongo assignment completedtopic id
proctoredTopicCompleteProctored topic completetopic id
videoTopicIdVideo topic viewedtopic 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/survey questions, assignment config, 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. The audio page 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.
  • Status create acceptance of values beyond draft/published/pending (e.g. authoring, loginRestriction): these are valid enum members and pass status validation, but draft/published are 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.