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 learner | POST /trail/add_course (session only) | POST /{org}/enrollments/{user_id}/{course_uuid} |
| Create an account | POST /users/{org_id} (signup) | POST /{org}/users |
| Mark an activity done | POST /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.comTwo 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 forgottenProvisioning 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/unenrollcourses/{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}/resetMarking 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}/groupsIf 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}/analyticsAggregate 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
/coursesAPI, 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.