KlickCourse DeveloperOpenAPI spec →

Course-builder API · v1

Build courses programmatically.

Create courses, modules and lessons from any application — an automation, your own backend, or an AI that generates a whole curriculum — with one authenticated call.

Quickstart

Everything lives under your institute’s own host. If your institute isacme.klickcourse.com, the API base is https://acme.klickcourse.com/api/v1. Create an API key in your admin (Admin → API keys), then:

curl https://acme.klickcourse.com/api/v1/admin/courses/import \
  -H "Authorization: Bearer kc_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Intro to Widgets",
    "slug": "intro-to-widgets",
    "modules": [
      { "title": "Getting started", "lessons": [
        { "title": "Welcome", "type": "text", "contentHtml": "<p>Hello!</p>" }
      ]}
    ]
  }'
# → 200 { "courseId": "…", "moduleCount": 1, "lessonCount": 1, "structure": {…} }

That single call builds the whole course tree in one atomic transaction and returns it as a draft.

Authentication

Send your key as a bearer token: Authorization: Bearer kc_…. The key is bound to the institute it was minted in, and it only works on that institute’s host — presenting it on another institute’s domain fails. Keys are shown once at creation and stored only as a hash; if one leaks, revoke it in your admin and it stops working immediately.

Keep keys server-side. Never ship a kc_ key in a browser, mobile app, or public repo.

Scopes

Keys carry explicit scopes:

ScopeGrants
course:writeCreate courses, modules, lessons; update & publish a course; import a full tree; draft landing-page metadata.
crm:readRead-only activity feeds under /admin/crm/* — enrollments, quiz attempts, certificates, course reviews — for CRM pipelines (n8n, Zapier, HubSpot, or an MCP-driven agent).

Keys deliberately cannot write to students, enrollments, payments, certificates or account credentials — those stay behind an admin sign-in with passkey step-up.

CRM feeds (crm:read)

Four institute-wide, cursor-style feeds power build-your-own CRM sync. Each returns rows oldest first; pass ?since=<ISO> (exclusive) with the last timestamp you processed, plus ?limit= (default 100, max 500).

GET /admin/crm/enrollments    # cursor: created_at
GET /admin/crm/quiz-attempts  # cursor: submitted_at (module checks + finals)
GET /admin/crm/certificates   # cursor: issued_at (revoked excluded)
GET /admin/crm/reviews        # cursor: updated_at (check "status" — edits & moderation re-emit)

Prefer zero code? The built-in HubSpot integration (Admin → HubSpot) pushes the same data into HubSpot contacts automatically — these feeds are for custom pipelines and other CRMs.

Create a course (atomic import)

POST /admin/courses/import — build a whole course in one transaction. Invalid input rolls the entire course back; nothing half-builds.

{
  "title": "string (required)",
  "slug": "lowercase-hyphenated (required, unique per institute)",
  "priceMinor": "0",                 // minor units, string; default "0" (free)
  "descriptionHtml": "<p>…</p>",     // sanitized server-side
  "maxAppliedCreditPct": 0,
  "publish": false,                  // true = go live now; false = draft (default)

  // landing page (all optional) — creates the course fully-formed
  "coverUrl": "https://…/thumb.jpg", // course thumbnail (https image URL)
  "subtitle": "One-line promise",
  "instructorName": "…", "instructorBio": "…",
  "instructorAvatarUrl": "https://…/avatar.jpg",
  "skills": ["…"], "tools": ["…"],

  "progressionMode": "open",         // or "quiz_gated": module N+1 unlocks when
                                     // module N is cleared (pass its check, or
                                     // finish its lessons if it has no check)

  "modules": [
    { "title": "Module title", "lessons": [
      {
        "title": "Lesson title",
        "type": "video | audio | document | text",
        "contentHtml": "<p>…</p>",   // any type; below the player on video/audio.
                                     // <a class="kc-cta" href> renders as a CTA button
        "embedUrl": "https://www.youtube.com/embed/…",  // YouTube/Vimeo allowlist
        "videoUrl": "https://yoursite.com/clip.mp4",    // self-hosted → Mux ingest (see below)
        "durationSeconds": 300,      // optional, non-negative integer
        "isPreview": false
      }
    ],
      // optional module check — a short quiz owned by this module. Unlimited
      // retakes, no certificate; under quiz_gated it unlocks the next module.
      "quiz": {
        "name": "Module 1 check",    // default: "<module title> check"
        "passThresholdPct": 80,      // 1-100, default 80
        "questions": [{
          "prompt": "…",
          "explanation": "Shown to the learner AFTER submitting — the why.",
          "options": [{ "text": "Right", "isCorrect": true }, { "text": "Wrong" }]
        }]
      }
    }
  ]
}

Returns { courseId, moduleCount, lessonCount, moduleQuizzes, progressionMode, publishedVersion, structure }. Re-posting the same slug returns 409 Conflict, so retries are safe.

Self-hosted video? Pass videoUrl (a public https mp4) instead of embedUrl on a video lesson. Mux pull-ingests it into a signed, enrollment-gated, completion-tracked asset — the same first-class video as an upload. The import response adds an ingests array with each lesson’s status (processing until Mux finishes). You can also ingest into an existing lesson via POST /admin/lessons/{lessonId}/ingest-video. YouTube/Vimeo still use embedUrl. Existing lessons are editable with the key too: PATCH /admin/lessons/{lessonId} updates title, contentHtml (e.g. add a CTA button below a video), embedUrl, and isPreview.

AI-generated courses

Because the import endpoint takes a complete course tree as JSON, an AI can author the whole thing. Ask a model to produce the tree, then post it — no special endpoint required:

// 1) Ask an LLM for a course tree (pseudo)
const tree = await llm.json(`Design a course on "${topic}".
Return { title, slug, modules: [{ title, lessons: [{ title, type, contentHtml }] }] }`);

// 2) Import it
await fetch("https://acme.klickcourse.com/api/v1/admin/courses/import", {
  method: "POST",
  headers: { Authorization: `Bearer ${KC_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ ...tree, publish: false }),   // review as draft, then publish
});

Prefer publish:false so a human reviews the AI draft before it goes live. After import, call POST /admin/courses/{id}/generate-metadatato have KlickCourse draft SEO/landing copy from the course’s own content. A ready-made n8n recipe (webhook → import) ships with the platform.

Pass category: "AI READY" to file the course under a catalog section — the name is matched against your existing categories (case-insensitively) and created if it’s new, so re-running an import never spawns a duplicate section. Imported courses land at the END of the catalog order; move them with POST /admin/courses/{id}/move.

Endpoint reference

All paths are relative to https://<institute>.klickcourse.com/api/v1 and require the course:write key.

MethodPathPurpose
POST/admin/courses/importAtomic whole-tree create
POST/admin/coursesCreate a course
PATCH/admin/courses/:idUpdate a course
POST/admin/courses/:id/modulesAdd a module
POST/admin/modules/:moduleId/lessonsAdd a lesson
POST/admin/courses/:id/generate-metadataAI-draft landing copy
GET/admin/courses/:id/structureRead the course tree
POST/admin/courses/:id/publishPublish (go live)
POST/admin/courses/:id/quizCreate the course quiz
POST/admin/modules/:moduleId/quizCreate a module check
GET/admin/courses/:id/module-quizzesRead all module checks
POST/admin/quizzes/:quizId/questionsAdd a quiz question
PATCH/admin/quizzes/:quizId/questions/reorderReorder questions
DELETE/admin/quizzes/:quizIdDelete a module check
GET/admin/courses/:id/quiz-attemptsPoll graded attempts (module passes → CRM)
PATCH/admin/quizzes/:quizId/templateSet the certificate template
POST/admin/courses/:id/unpublishUnpublish → draft
POST/admin/courses/:id/archiveArchive (retire from catalog)
DELETE/admin/courses/:idDelete (draft/archived only)
GET/admin/categoriesList catalog categories
POST/admin/categoriesCreate a category
PATCH/admin/categories/:idRename a category
DELETE/admin/categories/:idDelete a category (courses survive)
POST/admin/categories/:id/moveReorder a category section
POST/admin/courses/:id/moveReorder a course in the catalog

Errors

Standard HTTP status codes with a JSON { message } body:

400Invalid input (missing field, bad lesson type, negative duration)
401Missing / invalid / revoked API key
403Key lacks the required scope
404Course not found in this institute
409Slug already exists — safe to treat as 'already created'

Rate limits

Requests are rate-limited at the edge per key. Design for retries with exponential backoff; a 409 on a repeated slug is a safe idempotency signal, not a failure.

OpenAPI

A machine-readable spec is published at /openapi.json — import it into Postman, an SDK generator, or an AI tool-use definition.