DocsThe APIAPI reference

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.

Basehttps://underlayerhq.com/api/v1AuthBearer sk_live_…FormatJSON

This 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.

Paging

The three lists that grow without bound — /completions, /issued-certificates and /identities — take ?limit= (default 50, maximum 10,000) and ?offset=, and return a pagination object beside data. Ask for four thousand learners in one call if that is what you want; ask for more than the maximum and you are served the maximum rather than refused, with pagination.limit saying what you got and pagination.hasMore saying there is more. 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.

200json
{
  "data": [...],
  "pagination": { "limit": 50, "offset": 0, "total": 312, "hasMore": true }
}

Courses

GET/courses

Lists every course in your workspace, newest first.

200json
{
  "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).

POST/courses

Creates a draft course.

bodyjson
{
  "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.

GET/courses/:id

Fetches one course by id.

PATCH/courses/:id

Partially updates a course. Every field is optional — send only what changes.

bodyjson
{ "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.

DELETE/courses/:id

Deletes a course. Returns 204 with no body.

A course that has issued certificates cannot be deleted — it answers 409 with conflict. The serials printed on those PDFs have to keep resolving at /verify, and the people holding them — an employer, a regulator — have no relationship with your workspace and no way to know the course is gone. Unpublish it instead: that takes it out of circulation, which is what deleting is usually reached for. Completions are not certificates and still cascade.

409json
{
  "error": "conflict",
  "message": "This course has issued certificates, so it can't be deleted — their serials have to keep verifying. Unpublish it instead to take it out of circulation."
}
POST/courses/import/scorm

Creates 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.

examplebash
curl -X POST -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/zip" \
  --data-binary @course.zip \
  "https://underlayerhq.com/api/v1/courses/import/scorm"
GET/courses/:id/scorm

Builds 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.

examplebash
curl -L -H "Authorization: Bearer sk_live_..." \
  "https://underlayerhq.com/api/v1/courses/COURSE_ID/scorm?version=scorm2004" \
  -o course.zip

Requires the Scale plan or higher — a Sandbox/Build key gets a 403 plan_required.

POST/courses/import/csv

Creates a course from a CSV, using the same parser as the dashboard’s importer — a file that imports there imports here. The CSV is the raw request body, since that is what a spreadsheet export is; the title comes from ?title=. Imported courses arrive as drafts.

Rows the parser could not read are reported rather than dropped quietly: the 201 carries an issues array beside the course, and a file with no readable screens at all is a 400 with the same list. The column format is the one the dashboard importer documents on the import screen itself.

examplebash
curl -X POST -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: text/csv" \
  --data-binary @course.csv \
  "https://underlayerhq.com/api/v1/courses/import/csv?title=Onboarding%20101"

Media

Images, video and audio a block points at. The bucket is private, so what you store in a block is an identifier rather than something fetchable — the player signs it on the way out.

POST/media

Uploads one file as multipart/form-data: file, and kind of image, video or audio (default image). Up to 25MB.

Two URLs come back and they are not interchangeable. url is durable — put that in a block. previewUrl is signed and expires shortly, for showing the file back to whoever just uploaded it.

201json
{
  "data": {
    "url": "https://…/storage/v1/object/public/media/w_123/9f2c….png",
    "previewUrl": "https://…?token=…",
    "kind": "image",
    "bytes": 84213
  }
}
examplebash
curl -X POST -H "Authorization: Bearer sk_live_..." \
  -F "kind=image" -F "file=@diagram.png" \
  "https://underlayerhq.com/api/v1/media"

Themes

Requires the Scale plan or higher — a Sandbox/Build key gets a 403 plan_required.

GET/themes

Lists every theme in your workspace.

POST/themes

Creates a theme.

bodyjson
{
  "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.

GET/themes/:id

Fetches one theme by id.

PATCH/themes/:id

Partially updates a theme. Every field is optional — send only what changes.

DELETE/themes/:id

Deletes 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.

GET/certificates

Lists 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.

POST/certificates

Creates a template. Every tokens field is optional and falls back to the default. An empty issuerName uses your workspace name.

bodyjson
{
  "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.

free placementjson
{
  "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.

GET/certificates/:id

Fetches one template by id.

PATCH/certificates/:id

Updates a template. Sending tokens replaces the design wholesale — send the full object, not just changed keys.

DELETE/certificates/:id

Deletes 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.

GET/completions

Lists 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.

completedAfter takes an ISO 8601 timestamp and returns only runs that finished after it — the filter a nightly reconcile needs, since otherwise “what has finished since I last looked” means paging the whole history every night forever. When it is present the list is sorted by completedAt rather than startedAt, because anything reconciling wants them in the order they happened — and a long run started before a short one can finish after it. Store the completedAt of the last row you processed and pass it back next time.

200json
{
  "data": [
    {
      "id": "cm_123",
      "courseId": "c_123",
      "identityId": "i_456",
      "identityExternalId": "usr_8f2k",
      "status": "completed",
      "score": 75,
      "passed": true,
      "passedAtCompletion": true,
      "passingScoreAtCompletion": 50,
      "attempt": 2,
      "contentHash": "9f2c1b…",
      "source": "embed",
      "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. attempt is which of this learner’s finishes the row now reflects — 1 the first time, 2 after a retake — the same number the course.completed webhook carries, and null until they finish.

Which of the two verdicts you want. passed asks does this learner meet the bar we set today, so it moves when you change passingScore. passedAtCompletion asks what did we certify on the day, and never moves — passingScoreAtCompletion is the mark it was judged against. Use the first for a dashboard or a “who needs retraining” list, and the second anywhere a completion is evidence that training happened: a compliance record must not be rewritten by a later edit to the course. Both are null when the course has no pass mark, and on runs recorded before this was kept.

source says how much the learner id on a run is worth. embed means your backend signed it and we checked that signature. scorm means the LMS running the package asserted it — the package is on their origin, so nothing here can verify who was at the keyboard. Both are real completions; only one of them has a signature behind the name.

examplebash
curl -H "Authorization: Bearer sk_live_..." \
  "https://underlayerhq.com/api/v1/completions?identityExternalId=usr_8f2k&status=completed"
reconcilebash
curl -H "Authorization: Bearer sk_live_..." \
  "https://underlayerhq.com/api/v1/completions?completedAfter=2026-09-20T00:00:00Z&status=completed"
GET/completions/:id

One 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.

200json
{
  "data": {
    "id": "cm_123",
    "status": "completed",
    "score": 75,
    "passed": true,
    "passedAtCompletion": true,
    "passingScoreAtCompletion": 50,
    "attempt": 2,
    "contentHash": "9f2c1b…",
    "answers": { "b_quiz1": true, "b_quiz2": false }
  }
}
GET/completions/:id/attempts

Every finish of this course by this learner, newest first. A completion holds one row per (course, learner), so a retake overwrites the run before it — someone who passed at 90 last year and scored 40 on this year’s refresher leaves a single row reading 40. This is the log beside it, and it is append-only: nothing that happens later removes an attempt.

Each attempt carries what was true when it was recorded rather than what is true now — passingScore is the mark it was judged against, passed is what that mark decided, and contentHash names the material it was taken against. Editing the course afterwards changes none of them. This is the endpoint to read if a completion has to stand as evidence that training happened on a particular date.

200json
{
  "data": [
    {
      "id": "ca_789",
      "courseId": "c_123",
      "identityId": "i_456",
      "attempt": 2,
      "score": 40,
      "passed": false,
      "passingScore": 50,
      "contentHash": "4d81e7…",
      "source": "scorm",
      "locale": "ar",
      "timeSpentSeconds": 260,
      "startedAt": "2026-09-01T09:02:00.000Z",
      "completedAt": "2026-09-01T09:06:20.000Z",
      "answers": { "b_quiz1": false }
    }
  ],
  "pagination": { "limit": 50, "offset": 0, "total": 2, "hasMore": 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://underlayerhq.com/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.

GET/issued-certificates

Lists what your workspace has issued, newest first. Filters: courseId, identityId, plus limit and offset.

200json
{
  "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 }
}
GET/issued-certificates/:serial

Looks 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

GET/collections

Lists every collection in your workspace.

POST/collections

Creates a collection.

bodyjson
{ "name": "Onboarding track", "description": null }
GET/collections/:id

Fetches one collection, including its courses in display order.

PATCH/collections/:id

Renames or updates a collection’s description.

DELETE/collections/:id

Deletes a collection. Returns 204 with no body.

POST/collections/:id/courses

Adds a course to the end of the collection.

bodyjson
{ "courseId": "c_123" }
PATCH/collections/:id/courses/:courseId

Moves a course one position earlier or later in the collection.

bodyjson
{ "direction": -1 }
DELETE/collections/:id/courses/:courseId

Removes 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.

GET/courses/:id/translatable

Every string in the course that can be translated, with the text it currently says — the same set the dashboard’s own translation editor is built from, so the two cannot drift. Start here: the path of each field is the key you write back under content, and guessing those keys is otherwise the hard part of translating over the API.

sourceLocale is the language that text is in — the course’s own, not the workspace’s. group separates the course’s content from the player’s chrome, which matters when a translator is working through two hundred strings and needs to know which are buttons.

200json
{
  "data": {
    "sourceLocale": "ar",
    "fields": [
      { "path": "title", "group": "course", "labelKey": "courseTitle", "sourceText": "تركيب المحابس" },
      { "path": "s:welcome.title", "group": "s:welcome", "labelKey": "screenTitle", "sourceText": "لماذا يهم التوقيت؟" }
    ]
  }
}
GET/courses/:id/translations

Lists every locale translated for a course.

POST/courses/:id/translations

Starts a new (initially empty) locale for a course.

bodyjson
{ "locale": "fr" }
GET/courses/:id/translations/:translationId

Fetches one locale’s translated fields.

PATCH/courses/:id/translations/:translationId

Merges the given path/value pairs into the locale’s content. An empty string value clears that path back to the source-language fallback.

bodyjson
{ "content": { "s:welcome.title": "Bienvenue" } }
DELETE/courses/:id/translations/:translationId

Deletes a locale entirely. Returns 204 with no body.

Webhook endpoints

GET/webhooks

Lists every webhook endpoint in your workspace.

POST/webhooks

Registers a webhook endpoint. The response’s signingSecret is shown once — store it immediately, it can’t be retrieved again.

bodyjson
{ "name": "Prod", "url": "https://example.com/hook", "eventTypes": ["course.completed"] }
GET/webhooks/:id

Fetches one webhook endpoint by id (no secret included).

PATCH/webhooks/:id

Updates a webhook’s URL, subscribed events, or active state. Every field is optional.

bodyjson
{ "isActive": false }
DELETE/webhooks/:id

Deletes a webhook endpoint. Returns 204 with no body.

Identities

GET/identities

Lists 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.

POST/identities

Upserts 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.

bodyjson
{ "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" }
POST/identities/bulk

Creates 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.

bodyjson
{
  "identities": [
    { "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" },
    { "externalId": "usr_1a09", "name": "Marcus Webb" },
    { "externalId": "sso|auth0|9d2f" }
  ]
}
200json
{
  "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.

DELETE/identities/bulk

Deletes 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.

bodyjson
{ "externalIds": ["usr_8f2k", "usr_1a09"], "ids": ["8f2c…"] }
200json
{ "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.

GET/identities/:id

Fetches one identity by id.

DELETE/identities/:id

Deletes a learner identity. Returns 204 with no body.

AI generation

Requires the Build plan or higher — a Sandbox key gets a 403 plan_required.

POST/generate

Writes 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.

bodyjson
{
  "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.

202json
{ "data": { "id": "gj_123", "status": "queued" } }

Over your plan’s monthly generation allowance, this returns 429 rate_limited with the limit in the message.

GET/generate/:id

A 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.

200json
{
  "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

GET/me

Confirms which workspace and plan a key belongs to.

200json
{ "workspace_id": "w_456", "workspace_name": "Acme", "plan": "build" }