Skip to Content
Edit on GitHub

Headless Admin

The /admin router is the server-to-server surface for running LearnHouse behind your own frontend: provisioning people, enrolling them, tracking progress, and issuing certificates — all with an API token, no user session involved.

Every /admin endpoint requires an API token (Authorization: Bearer lh_…). A user session is rejected. This is the mirror image of the rest of the API, where several routers reject tokens instead.

Why this router exists

The obvious way to enrol someone is POST /trail/add_course — and that route rejects API tokens. So does /users. If you follow the build-a-platform guide, you authenticate each learner with their own JWT and act as them.

That works for a platform where learners log in. It does not work when your own system is the source of truth — an HR tool enrolling a new hire, a CRM granting course access on purchase, a nightly job reconciling a roster. Those have no user session to borrow.

/admin is the answer: one long-lived token, acting on behalf of the organization rather than as any particular person.

You want to…Session route/admin route
Enrol a learnerPOST /trail/add_course (session only)POST /{org}/enrollments/{user_id}/{course_uuid}
Create an accountPOST /users/{org_id} (signup)POST /{org}/users
Mark an activity donePOST /trail/… (session only)POST /{org}/progress/{user_id}/activities/{activity_uuid}/complete

Authentication and scoping

Mint a token under Dashboard → Developers → API Access (Pro plan or higher), then:

curl -H "Authorization: Bearer lh_xxx" \
  https://your-instance/api/v1/admin/acme/users/by-email/ada@example.com

Two boundaries are enforced on every call:

The org in the path must be the token’s org. Every route is /admin/{org_slug}/… and the slug is checked against the token. A token for acme calling /admin/globex/users gets a 403 — there is no cross-tenant mode.

Rights still apply. The token’s users, courses and usergroups permission buckets gate what it can do, exactly as they do elsewhere. A token with users.action_read but not users.action_create can look people up and cannot provision them.

Ordinary token traffic is not rate limited per request. Two /admin routes are: user provisioning at 30/min and email lookup at 60/min, both per token. Write your integration to back off rather than assuming headroom.

Provisioning people

# Create — returns the user, including the numeric id everything else needs
POST /admin/{org}/users
{ "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" }
 
# Find someone you did not create
GET  /admin/{org}/users/by-email/{email}
 
# Update a profile, change their org role
PATCH /admin/{org}/users/{user_id}
PATCH /admin/{org}/users/{user_id}/role
 
# Offboard: removes org membership, keeps the account and its history
DELETE /admin/{org}/users/{user_id}

Note what DELETE does — and does not. It removes the person from this organization. Their user record survives, because the same person may belong to other organizations, and because deleting it would cascade away authored content and other learners’ graded submissions.

For a genuine erasure request there are two dedicated routes:

GET  /admin/{org}/users/{user_id}/export      # everything held about them
POST /admin/{org}/users/{user_id}/anonymize   # right to be forgotten

Provisioning consumes the org’s member quota, the same as a signup. On a capped plan a bulk import can fail partway through with a 402 — read the response rather than assuming success.

Signing a user in

Two ways to hand a provisioned user a working session, for when your frontend needs them actually logged in:

POST /admin/{org}/auth/token        # issue a JWT directly
POST /admin/{org}/auth/magic-link   # issue a link to email them
GET  /admin/{org}/auth/magic-consume # the link's landing endpoint (browser-facing)

auth/token is the one to reach for from a backend that has already authenticated the person itself — a portal SSO’ing into LearnHouse, say. Treat the result exactly like a password login: it is a full session for that user.

Enrolment

POST   /admin/{org}/enrollments/{user_id}/{course_uuid}
DELETE /admin/{org}/enrollments/{user_id}/{course_uuid}
GET    /admin/{org}/enrollments/{user_id}              # one learner's courses
GET    /admin/{org}/courses/{course_uuid}/enrollments  # one course's learners
GET    /admin/{org}/courses/{course_uuid}/access/{user_id}

Bulk variants exist for roster syncs:

POST /admin/{org}/enrollments/bulk
POST /admin/{org}/enrollments/bulk/unenroll

courses/{uuid}/access/{user_id} is worth knowing: it answers whether someone can reach a course, accounting for enrolment, cohort membership and any paywall — rather than making you reassemble that from three other calls.

Progress

GET    /admin/{org}/progress/{user_id}                 # across all courses
GET    /admin/{org}/progress/{user_id}/{course_uuid}
POST   /admin/{org}/progress/{user_id}/activities/{activity_uuid}/complete
DELETE /admin/{org}/progress/{user_id}/activities/{activity_uuid}/complete
POST   /admin/{org}/progress/{user_id}/{course_uuid}/complete
POST   /admin/{org}/progress/{user_id}/{course_uuid}/reset

Marking an activity complete runs the same path a learner’s own click does: analytics, webhooks and certificate checks all fire. It is not a database write with the side effects skipped.

For the full picture — every chapter and activity with its state — use the trail endpoints:

GET /admin/{org}/trails/{user_id}
GET /admin/{org}/trails/{user_id}/courses/{course_uuid}

Cohorts

User groups are how you grant a set of people access to a set of courses at once, rather than enrolling everyone individually:

POST   /admin/{org}/usergroups
DELETE /admin/{org}/usergroups/{usergroup_uuid}
GET    /admin/{org}/usergroups/{usergroup_uuid}/members
POST   /admin/{org}/usergroups/{usergroup_uuid}/members/{user_id}
DELETE /admin/{org}/usergroups/{usergroup_uuid}/members/{user_id}
POST   /admin/{org}/usergroups/{usergroup_uuid}/courses/{course_uuid}
DELETE /admin/{org}/usergroups/{usergroup_uuid}/courses/{course_uuid}
GET    /admin/{org}/users/{user_id}/groups

If your identity provider already models these as directory groups, prefer SCIM over driving them by hand — it keeps membership in sync both ways.

Certificates

GET    /admin/{org}/certifications/{user_id}
POST   /admin/{org}/certifications/{user_id}/{course_uuid}/award
DELETE /admin/{org}/certifications/{user_id}/{user_certification_uuid}

Awarding manually is for the cases automatic issuance cannot see — credit for an in-person session, or a migration from a previous system.

Reporting

GET /admin/{org}/courses/{course_uuid}/analytics

Aggregate stats for one course. Unlike the /analytics router this does not require Tinybird — it is computed from the database, so it works on any deployment.

What this router does not do

  • Authoring. Creating courses, chapters and activities is the ordinary /courses API, which accepts tokens.
  • Reading course content. Public catalogue and activity bodies come from the normal endpoints; anonymous access works for published, public courses.
  • Anything browser-direct. CORS only permits the deployment’s own origin, so a third-party frontend must call this from its own server. That is the right shape anyway: an lh_ token has org-wide power and does not belong in a browser.