API reference
Every endpoint, with the request that produces it and the shape that comes back. The API is the product — the dashboard is one client of it, and the MCP server is another.
https://underlayer.outworx.io/api/v1AuthBearer sk_live_…FormatJSONThis is the complete, current API surface — courses, learner progress, issued certificates, themes, collections, translations, webhook endpoints, identities, generation and workspace info. Everything the dashboard can do, a key can do.
Responses are always a JSON object with a data key. Errors carry error (a stable machine-readable code) and message (for a human): invalid_request, unauthorized, not_found, rate_limited, plan_required, internal_error.
The three lists that grow without bound — /completions, /issued-certificates and /identities — take ?limit= (default 50, no maximum) and ?offset=, and return a pagination object beside data. Ask for as many as you like — the ceiling is how much data you have, so ?limit=1000000 on four thousand learners returns four thousand. A nonsensical value falls back to the default rather than erroring. Everything else on this page is configuration — a workspace has tens of themes, not thousands — and returns the full list.
{
"data": [...],
"pagination": { "limit": 50, "offset": 0, "total": 312, "hasMore": true }
}Courses
/coursesLists every course in your workspace, newest first.
{
"data": [
{
"id": "c_123",
"workspaceId": "w_456",
"themeId": null,
"title": "Onboarding 101",
"status": "published",
"screens": [...],
"sourceKind": "manual",
"direction": "ltr",
"customFonts": [],
"certificateId": null,
"passingScore": 70,
"quizFeedback": "deferred",
"navigation": "default",
"createdAt": "2026-08-01T00:00:00.000Z",
"updatedAt": "2026-08-01T00:00:00.000Z"
}
]
}passingScore is the percentage of graded blocks a learner must get right to pass and earn a certificate; null means any completion counts. quizFeedback is deferred (the learner sees what they got right at the end — the default, and what you want for an assessment) or immediate (marked as they answer, which suits practice). certificateId points at a certificate template; null issues the built-in default design. navigation is default (the player draws its own Back/Continue row) or hidden (it does not, and your button blocks are the way forward — swipe is disabled with it, the Contents menu stays).
/coursesCreates a draft course.
{
"title": "Onboarding 101",
"screens": [],
"themeId": null,
"certificateId": null,
"passingScore": 70,
"quizFeedback": "deferred",
"navigation": "default"
}Only title is required. A course is created as a draft — publish it with a PATCH. A themeId or certificateId belonging to another workspace is rejected with 400 invalid_request.
Screens follow the same shape the builder produces — each has an id, title, and an array of blocks. All 39 block types:
heading, text, image, video, audio, embed, button, divider, spacer, quiz_single_choice, quiz_multiple_choice, quiz_true_false, fill_in_blank, flashcard, match, hotspot, results, stack, steps, timeline, accordion, slideshow, callout, code, checklist, compare, quote, spec_table, resource, instructor_note, poll, reflection, ranking, scenario, timed_question, glossary, video_chapters, milestone, markdown
Ids are yours to mint and must be unique within the course — the player keys a learner’s answers and their resume position by block and screen id, so reusing one across two screens loses their work. The builder’s own embed renders exactly what you send here; there is no second renderer.
/courses/:idFetches one course by id.
/courses/:idPartially updates a course. Every field is optional — send only what changes.
{ "status": "published" }Updatable: title, screens, status (draft / published), themeId, certificateId, direction (ltr / rtl), passingScore, quizFeedback and navigation.
Sending screens replaces the whole array — it is not merged screen by screen. Read the course first if you are changing one screen out of many.
/courses/:idDeletes a course. Returns 204 with no body.
/courses/import/scormCreates a course from a SCORM package. Send the raw .zip as the request body — there is one file and no other fields, so there's no multipart form to build. Optional ?title= overrides the title the package declares. Imported courses arrive as drafts.
Responds 201 with the course plus a meta.kind of roundtrip (one of our own packages, restored exactly) or salvaged (a third-party package — outline and text only, withmeta.warnings explaining what couldn't come across). See SCORM.
curl -X POST -H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/zip" \
--data-binary @course.zip \
"https://underlayer.outworx.io/api/v1/courses/import/scorm"/courses/:id/scormBuilds and returns a SCORM package for the course as a application/zip body — not a JSON envelope, since the package is generated on demand from live course data and there is no durable URL to link to. Upload the .zip straight into your LMS.
Query parameters: version — scorm2004 (default) or scorm12; lang — export a translated locale instead of the source language; track=1 — also report runs back to your Underlayer analytics, keyed to the LMS's own learner id. Full detail on SCORM.
curl -L -H "Authorization: Bearer sk_live_..." \
"https://underlayer.outworx.io/api/v1/courses/COURSE_ID/scorm?version=scorm2004" \
-o course.zipRequires the Scale plan or higher — a Sandbox/Build key gets a 403 plan_required.
Themes
Requires the Scale plan or higher — a Sandbox/Build key gets a 403 plan_required.
/themesLists every theme in your workspace.
/themesCreates a theme.
{
"name": "Acme Brand",
"tokens": {
"concept": "editorial",
"mode": "auto",
"palette": { "primary": "#0f685c", "ink": "#16221f" },
"darkPalette": { "primary": "#4fd1c5" },
"displayFont": "Instrument Serif",
"fontFamily": "Inter",
"readingSize": 17,
"corners": "sharp",
"density": "airy",
"logoUrl": "https://example.com/mark.png"
},
"customFonts": []
}concept is the design a theme is a version of — stage, editorial, console, bright, calm, workbench or conversation. It carries the whole look: palette, type pairing, shapes, the chrome above the course and, for two of them, the arrangement of the screen itself. Everything else in tokens overrides a part of it, and anything you leave out stays the concept's own.
mode picks which palette learners see: light, dark, or auto to follow each learner's own system setting — which is why darkPalette is separate rather than derived.
/themes/:idFetches one theme by id.
/themes/:idPartially updates a theme. Every field is optional — send only what changes.
/themes/:idDeletes a theme. Courses assigned to it fall back to no theme — returns 204 with no body.
Certificate templates
Requires the Scale plan or higher — a Sandbox/Build key gets a 403 plan_required.
These are designs, not credentials. For the certificates actually handed to learners — and the serial that makes one checkable — see Issued certificates below.
/certificatesLists certificate templates. A template is the design of the PDF a learner gets on passing a course — issuer, logo, wording, colors, border and orientation. Assign one to a course by setting certificateId on it; courses with none assigned issue the built-in default design.
/certificatesCreates a template. Every tokens field is optional and falls back to the default. An empty issuerName uses your workspace name.
{
"name": "Completion certificate",
"tokens": {
"issuerName": "Jadarat Training Institute",
"eyebrow": "CERTIFICATE OF ACHIEVEMENT",
"bodyText": "has demonstrated competence in",
"signatureName": "Dr. Layla Hassan",
"signatureTitle": "Director of Learning",
"footerText": "Verify at example.com/verify",
"accentColor": "#4f5ef5",
"borderStyle": "double",
"orientation": "landscape",
"showScore": true,
"showDate": true
}
}logoUrl must be a PNG or JPEG — a PDF can't embed SVG or WebP, and one would silently vanish from the certificate.
Composition is part of the design too. layout picks one of the four arrangements (centred, left, minimal, sealed) and order rearranges the centre stack. Setting freeform places every element from positions instead: fractions of the page inside its margin, where x is the element's centre, y its top and w its width. Nothing stops two elements overlapping in that mode.
{
"tokens": {
"freeform": true,
"positions": {
"name": { "x": 0.5, "y": 0.37, "w": 0.8 },
"course": { "x": 0.5, "y": 0.54, "w": 0.8 },
"seal": { "x": 0.87, "y": 0.76, "w": 0.1 },
"qr": { "x": 0.11, "y": 0.82, "w": 0.06 }
}
}
}showQr prints the verification code beside the serial — on by default. Outside free placement it, and the seal, take a corner named by reading edge: qrCorner and sealCorner, one of topStart, topEnd, bottomStart, bottomEnd.
/certificates/:idFetches one template by id.
/certificates/:idUpdates a template. Sending tokens replaces the design wholesale — send the full object, not just changed keys.
/certificates/:idDeletes a template. Courses using it fall back to the built-in default design and keep issuing certificates. Returns 204 with no body.
Completions
A completion is one learner’s run of one course. There is exactly one per (course, learner): someone returning to a course resumes their row rather than starting a second, which is what makes lastScreenId and progressPercent mean where they are rather than where they have been. Read-only — completions are recorded by the player from what a learner actually did.
/completionsLists runs, newest first. This is the pull-based counterpart to the webhooks — useful when a delivery failed, when the integration was built after the fact, or when a report needs last quarter. It also includes the in-progress runs that never fire a completion event at all.
Filters: courseId, identityId, identityExternalId (your own id for the learner — no lookup needed first) and status (in_progress or completed). Plus limit and offset.
{
"data": [
{
"id": "cm_123",
"courseId": "c_123",
"identityId": "i_456",
"identityExternalId": "usr_8f2k",
"status": "completed",
"score": 75,
"passed": true,
"timeSpentSeconds": 412,
"lastScreenId": "s_summary",
"progressPercent": 100,
"locale": "ar",
"startedAt": "2026-08-01T09:12:00.000Z",
"lastSeenAt": "2026-08-01T09:18:52.000Z",
"completedAt": "2026-08-01T09:18:52.000Z"
}
],
"pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}score is the percentage of graded blocks answered correctly, or null if the course has none. passed compares it against the course’s current passingScore, and is null when the course has none — so raising the bar re-answers the question for old runs too, rather than leaving them claiming a pass at a mark that no longer exists. locale is the language the learner actually studied in.
curl -H "Authorization: Bearer sk_live_..." \
"https://underlayer.outworx.io/api/v1/completions?identityExternalId=usr_8f2k&status=completed"/completions/:idOne run, plus an answers object keyed by block id. The answers are only here and never on the list — they are free text and choice ids, so returning them by the hundred would turn a routine list call into a bulk export of what every learner typed.
{
"data": {
"id": "cm_123",
"status": "completed",
"score": 75,
"passed": true,
"answers": { "b_quiz1": true, "b_quiz2": false }
}
}Issued certificates
One row per certificate ever handed to a learner, carrying the serial printed on the PDF. Anyone can check a serial at https://underlayer.outworx.io/verify/<serial> — no account, no key — which is the whole reason certificates carry one.
Read-only, deliberately. A certificate is issued when a learner who genuinely completed the course downloads it, with the name, score and dates all derived server-side; an endpoint that minted one on request would be a way to produce a verifiable credential for a course nobody took. Issuing is idempotent — a learner downloading twice gets the same serial, and the certificate.issued webhook fires exactly once.
/issued-certificatesLists what your workspace has issued, newest first. Filters: courseId, identityId, plus limit and offset.
{
"data": [
{
"id": "ic_123",
"serial": "UL-34YT-T22D-M7BQ",
"courseId": "c_123",
"identityId": "i_456",
"identityExternalId": "usr_8f2k",
"score": 75,
"locale": "ar",
"issuedAt": "2026-08-01T09:19:04.000Z"
}
],
"pagination": { "limit": 50, "offset": 0, "total": 1, "hasMore": false }
}/issued-certificates/:serialLooks one up by the serial printed on the PDF — the identifier the holder actually has. Case and dashes don’t matter, so a serial retyped off paper still resolves: ul34ytt22dm7bq finds UL-34YT-T22D-M7BQ. Scoped to your workspace — it answers did we issue this?, where the public verification page answers is this real? for anyone. Unknown serials return 404 not_found.
Collections
/collectionsLists every collection in your workspace.
/collectionsCreates a collection.
{ "name": "Onboarding track", "description": null }/collections/:idFetches one collection, including its courses in display order.
/collections/:idRenames or updates a collection’s description.
/collections/:idDeletes a collection. Returns 204 with no body.
/collections/:id/coursesAdds a course to the end of the collection.
{ "courseId": "c_123" }/collections/:id/courses/:courseIdMoves a course one position earlier or later in the collection.
{ "direction": -1 }/collections/:id/courses/:courseIdRemoves a course from the collection (the course itself isn’t deleted). Returns 204 with no body.
Translations
Requires the Scale plan or higher — a Sandbox/Build key gets a 403 plan_required.
/courses/:id/translationsLists every locale translated for a course.
/courses/:id/translationsStarts a new (initially empty) locale for a course.
{ "locale": "fr" }/courses/:id/translations/:translationIdFetches one locale’s translated fields.
/courses/:id/translations/:translationIdMerges the given path/value pairs into the locale’s content. An empty string value clears that path back to the source-language fallback.
{ "content": { "s:welcome.title": "Bienvenue" } }/courses/:id/translations/:translationIdDeletes a locale entirely. Returns 204 with no body.
Webhook endpoints
/webhooksLists every webhook endpoint in your workspace.
/webhooksRegisters a webhook endpoint. The response’s signingSecret is shown once — store it immediately, it can’t be retrieved again.
{ "name": "Prod", "url": "https://example.com/hook", "eventTypes": ["course.completed"] }/webhooks/:idFetches one webhook endpoint by id (no secret included).
/webhooks/:idUpdates a webhook’s URL, subscribed events, or active state. Every field is optional.
{ "isActive": false }/webhooks/:idDeletes a webhook endpoint. Returns 204 with no body.
Identities
/identitiesLists learners, newest first. Takes ?limit= and ?offset= as above, plus ?search= — which matches name, email or external id, the same fields the dashboard filters on, so looking one person up does not mean paging through everybody.
/identitiesUpserts a learner by externalId — call this every time you know who the learner is, as often as you like. Returns 201 the first time, 200 on every call after.
{ "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" }/identities/bulkCreates or updates up to 500 learners in one call, upserting by externalId exactly as the single endpoint does — so re-sending a list that is mostly already here adds only what is new. Returns 201 when anything was created, 200 when everything already existed.
{
"identities": [
{ "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" },
{ "externalId": "usr_1a09", "name": "Marcus Webb" },
{ "externalId": "sso|auth0|9d2f" }
]
}{
"data": {
"created": 2,
"updated": 1,
"identities": [ { "id": "…", "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com", "createdAt": "…" } ]
}
}A repeated externalId inside one request is a 400, not a last-one-wins: two rows for one learner means one of them was meant to be somebody else.
/identities/bulkDeletes up to 500 learners in one call, by our ids or by your own — send either, or both. The keys travel in the body rather than the query string, which has a length limit a body does not.
{ "externalIds": ["usr_8f2k", "usr_1a09"], "ids": ["8f2c…"] }{ "data": { "deleted": 2, "requested": 3, "identities": [ { "id": "…", "externalId": "usr_8f2k" } ] } }deleted is what actually went, which can be fewer than requested — an id that was already gone is not an error, but it is not reported as a deletion either.
/identities/:idFetches one identity by id.
/identities/:idDeletes a learner identity. Returns 204 with no body.
AI generation
Requires the Build plan or higher — a Sandbox key gets a 403 plan_required.
/generateWrites a draft course from a prompt, optionally grounded in a URL you point it at. Returns 202 with a job id rather than the course: writing one takes tens of seconds, which is longer than a caller should hold a request open.
{
"prompt": "A refresher on our refund policy for new support agents",
"audience": "support agents in their first month",
"screensTarget": 8,
"sourceUrl": "https://example.com/refund-policy",
"locale": "ar"
}Only prompt is required (up to 2,000 characters). screensTarget caps at 20. locale is a BCP-47 code and sets the language the course is written in — reading direction is derived from it, because asking an author to pick both is asking them to get it wrong. File uploads belong to the dashboard, where there is a browser to pick a file with.
{ "data": { "id": "gj_123", "status": "queued" } }Over your plan’s monthly generation allowance, this returns 429 rate_limited with the limit in the message.
/generate/:idA job moves queued → processing → completed (with a courseId) or failed (with an error). Poll until it reaches one of the last two — every path through generation lands on a terminal status, so a job does not sit in processing forever. Our own dashboard polls every 2.5 seconds, which is a reasonable cadence to copy: a course typically takes tens of seconds to write.
Polling is the only mechanism — there is deliberately no webhook for generation yet. The events we deliver are all learner and course lifecycle; none of them fires when a job finishes, so do not wait on one.
{
"data": {
"id": "gj_123",
"status": "completed",
"courseId": "c_789",
"prompt": "A refresher on our refund policy...",
"sourceUrl": null,
"audience": "support agents in their first month",
"screensTarget": 8,
"error": null,
"createdAt": "2026-08-01T09:00:00.000Z",
"completedAt": "2026-08-01T09:00:41.000Z"
}
}The generated course arrives as a draft, and only ever contains these block types:
heading, text, divider, quiz_single_choice, quiz_multiple_choice, quiz_true_false, fill_in_blank, flashcard, match, stack, steps, timeline, accordion, callout, quote, code, compare, spec_table, checklist, glossary, poll, reflection, ranking, timed_question, milestone
Media blocks are excluded on purpose — image, video, audio, embed, hotspot and slideshow all need a real asset URL, and a model asked for one invents it. A course full of dead links is worse than a course with no media, so you add assets afterwards. Blocks that come back malformed (a single-choice question with two correct answers, a flashcard with no back, a two-item timeline with one item) are dropped rather than repaired — a guessed correct answer is a wrong answer presented with confidence.
Workspace info
/meConfirms which workspace and plan a key belongs to.
{ "workspace_id": "w_456", "workspace_name": "Acme", "plan": "build" }