Migrating Content into LearnHouse
This section explains how to bring existing course content into LearnHouse using the public API. There are two supported approaches:
- Programmatic Migration — you script every step (create the course, then chapters, then activities, then upload files). Use this when you need full control of the resulting structure — typical for migrating from another LMS where you already know the chapter/activity layout.
- AI-Assisted Migration — you upload a folder of media (videos, PDFs, images, audio) and LearnHouse asks Gemini to propose a chapter/activity tree. You review and confirm, and a single endpoint creates the entire course in one transaction. Use this when you have a pile of files but no predefined structure.
Both approaches share the same underlying data model — a Course contains Chapters, and each Chapter contains an ordered list of Activities.
All endpoints below are prefixed with /api/v1/. In local development the base URL is http://localhost:1338/api/v1/. In production it is served at /api/v1/ on your instance domain.
Authentication
Every migration call must be authenticated. There are two token types:
- JWT access tokens (
POST /api/v1/auth/login) — a user session, 8-hour lifetime, refreshable. - API tokens (Pro plan) — prefixed with
lh_, issued per organization, no expiry.
Both are sent the same way:
curl http://localhost:1338/api/v1/courses/?org_id=1 \
-H "Authorization: Bearer lh_your_api_token_here"API tokens are bound to a single organization at creation time. Any attempt to operate on a different org_id is rejected at the auth layer, so a leaked token cannot reach across tenants.
API tokens cannot call every endpoint. A small set of routers — including the AI-assisted migration endpoints under /api/v1/courses/migrate/* and several user-scoped endpoints like /users/profile — explicitly reject API tokens (HTTP 403, "API tokens cannot access this resource"). For these calls, you need a user JWT. The supported pattern is the admin token-swap: use your API token once to mint a JWT on behalf of any user in your organization, then use that JWT for the protected calls.
# Mint a user JWT using your API token
curl -X POST 'http://localhost:1338/api/v1/admin/{org_slug}/auth/token' \
-H 'Authorization: Bearer lh_your_api_token' \
-H 'Content-Type: application/json' \
-d '{"user_id": 1}'
# → { "access_token": "eyJ...", "token_type": "bearer", "user_id": 1, "user_uuid": "..." }The minted JWT is a normal user session and works on every endpoint, including the migration endpoints. The API token must have the users.action_read permission and the target user must belong to the same organization.
| Endpoint group | API token | JWT |
|---|---|---|
POST /courses/, PUT /courses/{uuid}, DELETE /courses/{uuid} | ✓ | ✓ |
POST /chapters/, PUT /chapters/{id}/..., DELETE /chapters/{id} | ✓ | ✓ |
POST /activities/, /activities/video, /activities/external_video, /activities/documentpdf | ✓ | ✓ |
POST /blocks/{image,video,pdf,audio} | ✗ | ✓ |
POST /assignments/, POST /assignments/{uuid}/tasks | ✓ | ✓ |
POST /courses/migrate/upload / /suggest / /create | ✗ | ✓ |
The blocks router has Depends(get_non_api_token_user) at the router level, so even though individual block handlers accept anonymous + JWT users, an lh_* API token is rejected with 403 "API tokens cannot access this resource". The same is true for the AI-assisted migration endpoints. For both, use the admin token-swap to mint a JWT first (see callout above).
See Authentication for token issuance.
The data model in one diagram
Organization (org_id)
└── Course (course_uuid, course_id)
└── Chapter (chapter_uuid, chapter_id)
└── Activity (activity_uuid, activity_id)
└── (optional) Block (block_uuid) ← media files referenced from a dynamic pageTwo ID conventions to keep straight:
*_uuidstrings (e.g.course_uuid,activity_uuid,chapter_uuid) are the public identifiers used in URLs and mostGETendpoints.*_idintegers (e.g.course_id,chapter_id) are the internal primary keys. They are required when creating a child resource (a chapter needscourse_id, an activity needschapter_id).
Both are returned in every create response, so capture them as you go.
Activity types at a glance
activity_type | activity_sub_type | What it is | How content is supplied |
|---|---|---|---|
TYPE_DYNAMIC | SUBTYPE_DYNAMIC_PAGE | Rich-text page (TipTap / ProseMirror) | JSON document in content |
TYPE_DYNAMIC | SUBTYPE_DYNAMIC_MARKDOWN | Page rendered from a remote Markdown URL | content: { markdown_url } |
TYPE_DYNAMIC | SUBTYPE_DYNAMIC_EMBED | Iframe embed (Google Doc, Notion, etc.) | content: { embed_url } |
TYPE_VIDEO | SUBTYPE_VIDEO_HOSTED | Self-hosted video file | Multipart upload to /activities/video |
TYPE_VIDEO | SUBTYPE_VIDEO_YOUTUBE | Embedded YouTube / Vimeo video | JSON POST /activities/external_video |
TYPE_DOCUMENT | SUBTYPE_DOCUMENT_PDF | PDF document | Multipart upload to /activities/documentpdf |
TYPE_DOCUMENT | SUBTYPE_DOCUMENT_DOC | Generic document | Reserved — not exposed via dedicated upload endpoint |
TYPE_ASSIGNMENT | SUBTYPE_ASSIGNMENT_ANY | Graded assignment with tasks | Three-step: activity shell + assignment record + at least one task |
TYPE_CUSTOM | SUBTYPE_CUSTOM | Free-form structured content | JSON content of any shape |
TYPE_SCORM | SUBTYPE_SCORM_12 / SUBTYPE_SCORM_2004 | SCORM packages | Reserved — handled via the SCORM uploader |
The full per-type content JSON schemas, including the dynamic page block list, are documented in Activity Types Reference.
Quick start: the four building blocks
A complete migration boils down to four call types, in order:
- Create a course.
POST /api/v1/courses/?org_id={org_id}(multipart). Capturecourse_uuidandidfrom the response. - Create chapters.
POST /api/v1/chapters/(JSON), one per chapter, passingcourse_idandorg_id. Capture each chapter’sid. - Create activities. Either the generic
POST /api/v1/activities/(JSON, for dynamic / embed / markdown / custom / assignment shells) or one of the typed endpoints/activities/video,/activities/external_video,/activities/documentpdf(multipart, for hosted media). - (Optional) Upload media blocks referenced from a dynamic page via
/api/v1/blocks/{image,video,pdf,audio}(multipart) and embed them in the page’s TipTap content.
A copy-pasteable bash script for the full flow lives in Programmatic Migration.
Common pitfalls
chapter_id and course_id are integers, not UUIDs. Many GET endpoints take a UUID, but every create body wants the integer primary key. Read it from the previous response’s id field, not its chapter_uuid / course_uuid field.
Activity type is immutable. You cannot change activity_type or activity_sub_type after creation. Delete and recreate the activity if you picked the wrong type.
Modifying content creates a new version. Each PUT /api/v1/activities/{activity_uuid} that touches content increments the activity’s current_version. This is normal — learners always see the published version — but expect your migration to leave a tidy version history.
Set published: false while migrating and flip it to true only once you have spot-checked the result. Unpublished activities are returned by GET /courses/{course_uuid}/meta?with_unpublished_activities=true so you can preview the final tree before exposing it to learners.
Typed-upload endpoints (/activities/video, /activities/external_video, /activities/documentpdf) always create activities with published: false regardless of any payload field. Follow the create call with PUT /api/v1/activities/{activity_uuid} setting {"published": true} to publish them. Only the generic POST /activities/ honours published on creation.
TYPE_ASSIGNMENT is a three-step create. A bare assignment activity renders as an empty panel. You also need to POST /api/v1/assignments/ to attach the title/description/grading, and at least one POST /api/v1/assignments/{uuid}/tasks for a submission UI. See Activity Types Reference — Assignments.
TYPE_CUSTOM has no built-in renderer. A custom activity will appear as an empty container in the learner viewer until you ship your own React component for it. Prefer SUBTYPE_DYNAMIC_PAGE (TipTap) or SUBTYPE_DYNAMIC_EMBED (iframe) unless you specifically have custom frontend code in place.
Where to next
- Programmatic Migration — step-by-step guide with curl examples for each call.
- Activity Types Reference — the exact
contentJSON shape for every activity sub-type, including TipTap blocks for dynamic pages. - AI-Assisted Migration — the three-call upload / suggest / create flow for bulk file imports.