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)
  "salesVideoUrl": "https://…/trailer.mp4",       // landing-hero trailer, self-hosted (see below)
  "previewEmbedUrl": "https://player.vimeo.com/video/…", // …or a YouTube/Vimeo embed instead
  "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.

Landing-page sales video. The course page hero shows, in this order: a self-hosted trailer, then a YouTube/Vimeo embed, then the cover image. Pass salesVideoUrl (a public https mp4) to have Mux pull-ingest a first-party, signed trailer — nothing is published to YouTube or Vimeo — or previewEmbedUrl for the embed. Setting both is fine: the self-hosted one wins, and removing it falls back to the embed. Like lesson videos it is ingested after the import commits, so the response adds salesVideo with its status. On an existing course: POST /admin/courses/{courseId}/sales-video/ingest, GET …/sales-video/playback to review it (works on a draft), and DELETE /admin/courses/{courseId}/sales-video to remove it.

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.

MCP server

Every institute also exposes a remote MCP server, so an AI assistant that speaks the Model Context Protocol can build courses directly — no glue code. It is the same course-builder API behind the same key, with the authoring methodology and the platform’s constraints attached as a prompt and as readable resources.

{
  "mcpServers": {
    "klickcourse": {
      "type": "http",
      "url": "https://<institute>.klickcourse.com/mcp",
      "headers": { "Authorization": "Bearer kc_your_key_here" }
    }
  }
}

Use the same course:write key you mint in Admin → API keys. The tenant is resolved from the host, so a key only ever works on its own institute’s URL.

Everything it creates is a draft. There is no publish tool, and there is no flag that turns one on — publishing stays a decision you make in the admin after reading what was built. Tools for enrolments, payments, certificates and key management are not exposed either.

What the server offers:

  • Toolsvalidate_course_spec (a pre-flight check that writes nothing), import_course, plus granular module, lesson, quiz and catalog tools for building or patching a course in pieces.
  • A course_design prompt — the full course-design methodology: intake, structure, how to write an assessment that tests judgment rather than recall, and the register to write in.
  • Guardrail resources — the HTML sanitizer subset, import limits, media rules and the assessment model, at klickcourse://guardrails/…, so the assistant reads the constraints instead of guessing at them.

import_course refuses a spec the import would reject, and tells you exactly which rule was broken. It also refuses one that is technically valid but unfinished — a blank thumbnail, no downloadable worksheet, a module with no video slot — unless you pass acknowledgeGaps. That second check exists because a course can pass every technical rule and still be one nobody would put their name on.

Export, import, transfer

A course can leave an institute two ways, and the difference is entirely about video.

  • A file — a .kcourse.zip you download, keep as a backup, version, or hand to someone outside the platform. It carries the whole course tree, images and lesson documents. It cannot carry video.
  • A transfer link — a single-use URL you send to another institute’s admin. Because it never leaves the platform, lesson video is copied across too.

Video is the hard constraint, not an oversight. Lesson videos are stored with signed playback and no static rendition, so there is no downloadable file to put in a ZIP. Every video an export cannot carry is named individually in the archive’s gaps list and in the import report — a course that arrives quietly missing six videos looks broken, one that arrives with the six named is a task somebody can finish.

# What will and won't travel — before you download anything
curl -H "Authorization: Bearer $KC_KEY" \
  https://acme.klickcourse.com/api/v1/admin/courses/$ID/export/preview

# The archive itself
curl -H "Authorization: Bearer $KC_KEY" -OJ \
  https://acme.klickcourse.com/api/v1/admin/courses/$ID/export

# Read one back — always creates a NEW DRAFT course
curl -X POST -H "Authorization: Bearer $KC_KEY" \
  -H "Content-Type: application/zip" --data-binary @course.kcourse.zip \
  https://other.klickcourse.com/api/v1/admin/courses/import-file

Inside the ZIP, manifest.json holds the course in exactly the shape POST /admin/courses/import accepts, so an archive is readable and diffable with ordinary tools. Binaries sit under media/ and docs/, each with a sha256 recorded in the manifest — a truncated or altered archive is rejected on arrival rather than discovered by a learner in week three.

What never travels. An import is a course, not a business: price, credit settings, coupons, learners, progress, quiz attempts, certificates and analytics all stay with the sender. Everything arrives as a draft. Files are re-uploaded under the receiving institute’s own storage keys, so nothing in an imported course points back at where it came from.

An uploaded archive is treated as untrusted input: entry paths, entry count and unpacked size are bounded, digests are checked, and the course is rebuilt from an explicit allowlist of fields. A hand-written archive cannot publish itself, set a price, name a storage key, or ask the server to fetch a URL. If its slug is already taken, the import suffixes it (-2) rather than failing — a receiving institute should not have to negotiate the sender’s slug.

A transfer link is a bearer capability. Anyone holding it can claim the course, once, before it expires (24 hours by default, 7 days at most; ttlHours is clamped server-side). The raw token is returned to you exactly once and only its hash is stored, so a lost link cannot be recovered — cancel it and mint another. Both institutes’ audit logs record the hand-off.

It is redeemed on the DESTINATION’s address, not yours. Pass destInstitute and the URL comes back ready to send; omit it and you get a {institute} placeholder and needsHost: true. Opening a link on the sending institute’s own host consumes nothing — that page offers to take you to the right address instead.

Minted one by mistake, or sent it to the wrong person? List what is outstanding with GET /admin/courses/{id}/transfers and kill any of them with POST /admin/transfers/{id}/revoke. A link that has already been claimed cannot be revoked — the receiving institute has its own copy by then.

# Sender: mint a link for the institute that will RECEIVE it
curl -X POST -H "Authorization: Bearer $KC_KEY" -H "Content-Type: application/json" \
  -d '{"ttlHours":24,"destInstitute":"other"}' \
  https://acme.klickcourse.com/api/v1/admin/courses/$ID/transfer
# → { "url": "https://other.klickcourse.com/transfer/<token>", "needsHost": false, ... }

# Changed your mind: see what is outstanding, then kill one
curl -H "Authorization: Bearer $KC_KEY" \
  https://acme.klickcourse.com/api/v1/admin/courses/$ID/transfers
curl -X POST -H "Authorization: Bearer $KC_KEY" \
  https://acme.klickcourse.com/api/v1/admin/transfers/$TRANSFER_ID/revoke

# Receiver: claim it into THEIR institute (videos copied too)
curl -X POST -H "Authorization: Bearer $THEIR_KEY" \
  https://other.klickcourse.com/api/v1/transfer/<token>/redeem

Redemption is single-use even under simultaneous calls: exactly one caller wins and the rest get 410. The token stays spent even if the import then fails, so a failed redemption costs a new link rather than risking a double import.

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
GET/admin/couponsList discount codes
POST/admin/couponsCreate a discount code (needs coupon:write)
PATCH/admin/coupons/:idChange limits / turn a code off (needs coupon:write)
DELETE/admin/coupons/:idDelete an unused code (needs coupon:write)
GET/admin/coupons/referrersWhat each referrer's codes drove
GET/admin/courses/:id/export/previewWhat an export will and won't carry
GET/admin/courses/:id/exportDownload the course as a .kcourse.zip
POST/admin/courses/import-fileCreate a course from an archive (new draft)
POST/admin/courses/:id/transferMint a single-use transfer link
GET/transfer/:tokenLook at a transfer link (metadata only)
POST/transfer/:token/redeemClaim the course into your institute
GET/admin/courses/:id/transfersList transfer links (live, used, revoked, expired)
POST/admin/transfers/:id/revokeCancel an unclaimed transfer link

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.