Concepts
The vocabulary, in plain language. Read this once and the rest of the documentation stops needing to explain itself.
The whole model in one picture
Underlayer has exactly three levels of content. Not four, not a hierarchy of modules and lessons and units — three:
- Screen“The basics”
- Blockheading“Refunds in 30 seconds”
- Blocktext“Agents can refund up to $200…”
- Blockimagephoto of the admin panel
- Screen“Quick check”
- Blockquiz_single_choice“What can you refund…?”
A course is the thing you publish and embed. A screen is one page of it — the learner sees one at a time and presses Continue. A block is one element on that page.
All of it is stored on the course itself, as one JSON array. That is why PATCH /courses/:id with screens replaces everything: you are writing the whole document, not editing a row in a screens table. There is no screens table.
What a screen is for
One idea. A screen should be what a learner takes in before moving on — not a chapter, not a section, not “everything about refunds”. If you find yourself writing six paragraphs on one screen, that is usually three screens.
Screens are ordered, and the order in the array is the order the learner walks through. A button block can jump to a specific screen, which is how you build branching.
Which blocks count toward a score
Only six of the thirty-eight:
quiz_single_choice, quiz_multiple_choice, quiz_true_false, fill_in_blank, ranking, timed_question
Everything else is content, however interactive it feels. Flashcards, matching pairs and hotspots are things a learner does, but they are not marked — deliberately, so that the score a learner sees on screen is always the score you get back over the API, in a webhook, and in a SCORM package. A silently different denominator in one of those places is the kind of bug nobody finds for months.
Score is a percentage of graded blocks answered correctly. A course with no graded blocks has a score of null, not zero — it was never a test.
Learners, and why we don't have accounts
Underlayer has no learner accounts, no sign-up, no password reset and no student portal. Your users are already signed in to your product; making them create a second account somewhere else is the single most reliable way to stop them finishing a course.
Instead there are identities. An identity is your own user id — whatever your database calls them — with an optional name and email attached. You tell us who someone is by putting that id in the embed URL:
/embed/c_123?identity=usr_8f2kYour backend builds that URL, so your backend is the thing deciding who this is. We take its word for it, which is exactly right: you already authenticated them, and re-authenticating in an iframe would be theatre. It also means the embed URL should be generated server-side, not assembled in the browser from something a user can edit.
Attach a name and email with POST /v1/identities — it upserts, so call it whenever you like. The email is what a completion notification goes to; without one, no email is sent.
Anonymous runs are allowed and still record views and answers. What they cannot do is resume, earn a certificate, or appear in /v1/completions — all three need to know who the person is.
Completions: one per learner, per course
A completion row is created the first time someone opens a course and updated as they go. It is not a log of attempts — the same person returning to the same course continues the same row. That is what makes lastScreenId and progressPercent mean where they are now.
status is in_progress until they reach the end, then completed. Progress is stored on our side, not in the browser — so a learner who starts on their laptop and finishes on their phone picks up where they left off.
Passing, and certificates
passingScore on the course is the bar, as a percentage. Null means there is no bar and finishing counts.
A learner who meets it can download a PDF certificate. Every fact on it — their name, the course, the date, the score — is re-derived server-side from the stored completion, so there is no way to request a certificate for a course nobody took, whatever is put in the URL.
Each certificate carries a serial like UL-34YT-T22D-M7BQ, printed on the document along with a URL that checks it. Anyone can open that URL — an employer, a regulator — with no account and no key. Without a serial a certificate is a PDF anyone could have typed, which is most of what a certificate is for.
What the PDF looks like comes from a certificate template — issuer, logo, wording, colours, border. Templates and issued certificates are different things and have separate endpoints: one is a design you configure once, the other is the ledger of that design being handed to people.
Themes and translations
A theme is a workspace-level look — font, colours, logo — that courses point at. Themes are live-linked: edit one and every course using it changes immediately.
A translation is a course’s text in another language, stored per locale (22 are available). Serve one by adding ?lang=ar to the embed URL. Anything untranslated falls back to the source language rather than showing a blank, and a right-to-left locale flips the layout automatically.
Two ways to reach learners
Embed — an iframe in your own product. Live events, resume, certificates, and you control who the learner is. This is the one to use if your users are in your app.
SCORM — a .zip you upload to a corporate LMS. Same player, no iframe, and the LMS does the tracking. Use it when your learners live inside somebody else’s system. See SCORM.
Glossary
Your account. Everything — courses, themes, keys, learners — belongs to exactly one, and an API key is scoped to it automatically. You never pass a workspace id anywhere.
The thing you publish and embed. Holds screens.
One page. The learner sees one at a time.
One element on a screen — a heading, an image, a question.
A learner, keyed by your own user id. Not an account; there is nothing to log into.
One learner’s run of one course — progress, score, when they finished.
A draft course’s embed link 404s. Publishing makes it live; unpublishing takes it down without losing anything.
A design. Configure once, assign to any number of courses.
A specific certificate given to a specific learner, with a serial anyone can verify.
The code printed on a certificate, in the form UL-XXXX-XXXX-XXXX. Publicly checkable.
One learner opening a published course, counted per calendar month in UTC. This is what plans are metered on — traffic served rather than people reached, which is why learners themselves are unlimited on every plan.
The file format corporate LMSs import. We speak both 2004 4th Edition and 1.2, in and out.
Model Context Protocol — how Claude, Cursor and similar tools call Underlayer as a set of tools. Same capabilities as the REST API, from inside a conversation.
The free plan. Everything works, with a visible marker in the embed, a watermark on certificates, and a monthly cap on course views.