DocsDeliveringEmbedding

Embed

The learner-facing surface: one iframe URL, and a live event stream your own page can react to without waiting on a webhook.

The embed URL

Every published course is served at /embed/{course_id}. It needs no session and no key — it is scoped by the course's own unguessable id, and a draft course 404s exactly like a missing one, so this never leaks that an unpublished course exists.

html
<iframe
  src="https://underlayerhq.com/embed/COURSE_ID?identity=usr_8f2k&expires=1767225600&signature=9c1e…&lang=ar"
  width="100%"
  height="600"
  style="border:0"
></iframe>

That is the learner's side. If your users also write courses, the editor embeds the same way — see the embedded builder.

Query parameters

identity

Your own user id for this learner — the same value you use with the Identities API. Your backend controls the iframe src, so it is the one deciding who this is. Without it, views and answers are still recorded anonymously, but no completion row is written, nothing resumes, and no certificate can be issued — all three need a known learner.

expires

Unix time, in seconds, after which the signature stops working. At most 7 days ahead. See signing the identity.

signature

Hex HMAC-SHA256 of identity.expires, keyed with your workspace's embed signing secret.

lang

Renders a translation instead of the source language — any locale you've added on the course's Translations page (22 are offered). Falls back silently to the source language if that locale has no translation. A right-to-left locale forces RTL layout regardless of the course's own configured direction.

Signing the identity

There is no second login because we never try to share your session. Whatever your auth is — cookies, SSO, something of your own — your backend has already decided who the user is by the time it renders the page. It says so to us by signing their id, and we believe the signature, not the query string. Without one, anyone who could guess a user's id could open a course as them, record their progress and download their certificate.

The secret is on the Embedding page of the dashboard. Keep it server-side, like an API key: sign when you render the page, never in the browser.

nodejavascript
import { createHmac } from "crypto";

function embedUrl(courseId, userId) {
  const expires = Math.floor(Date.now() / 1000) + 10 * 60; // good for 10 minutes
  const signature = createHmac("sha256", process.env.UNDERLAYER_EMBED_SECRET)
    .update(`${userId}.${expires}`)
    .digest("hex");
  const params = new URLSearchParams({ identity: userId, expires: String(expires), signature });
  return `https://underlayerhq.com/embed/${courseId}?${params}`;
}

expires only has to cover the page load: once the signature checks out, the player carries a session of its own for twelve hours, so a short expiry does not cut a learner off mid-course. A link that has expired, or whose signature does not match, shows the learner a short “open this course again” screen instead of the course — nothing is recorded under a name we could not prove. The reason is on that screen's data-embed-refused attribute (expired, invalid or unsigned) for when you are debugging.

New workspaces require signing. Workspaces created before that became the default keep whatever they had, so embeds already in the wild carry on working — but with the requirement off, an unsigned identity is believed, which means anyone who can edit the URL can complete a course as one of your learners. The switch is Require signed embed URLs on the same page; turn it on once your backend signs. A course opened with no identity at all still runs anonymously either way.

Rotating the secret ends every signed link and open session at once, and also invalidates every SCORM package already uploaded to an LMS — that is what makes it the thing to do if the secret leaks, and it means a rotation is followed by a re-export. SCORM does not use signed embed URLs: a package proves itself with a token baked in at export time, and the learner it names is the one the LMS reports, so there it is the LMS's login you are trusting. Completions record which of the two they came from.

Resuming

A learner opened with an identity comes back to the screen they left on, with an explicit start over alongside it. Position is stored server-side on their completion row, not in the browser — so it survives a new device, a cleared cache and a different browser, which is the difference between a course someone can finish over a week and one they have to finish in a sitting.

You can read the same position over the API: lastScreenId, progressPercent and lastSeenAt on GET /v1/completions. Anonymous runs have no resume point to store.

What the learner sees

The player ships with a contents panel (every screen, which ones they have visited, and a jump to any of them), a pinned progress and navigation bar, and a completion screen — with score, certificate link and whatever the author put there — that appears whether or not the course was built with a results block. It respects prefers-reduced-motion, is fully keyboard-navigable, and mirrors for right-to-left locales.

Whether a learner is told they got a question right as they answer, or only at the end, is a course setting: quizFeedback, either immediate or deferred (the default). Deferred is right for an assessment; immediate suits practice.

The Back/Continue row is a course setting too: navigation, either default (shown) or hidden. Hide it for a course that drives itself — where your own button blocks are the way forward and a second set of controls underneath them is a route around the path you built. Swipe is disabled with it; the Contents menu stays, and quiz gating is unaffected either way.

Courses in a Sandbox workspace carry a visible sandbox marker and a “Powered by Underlayer” line inside the embed. Both disappear on every paid plan.

Live events

The player broadcasts as the learner moves, so your page can unlock content, show a badge or redirect immediately — no webhook round-trip. Events go out two ways: postMessage to the parent window when the course is in an iframe, and a underlayer:track CustomEvent on window for when it isn't.

Every payload carries source: "underlayer" — filter on it, since your page will see other message traffic too. These are public UI signals, not a trusted channel: treat them as a prompt to update your interface, and use webhooks (which are signed) when you need to act on a completion server-side.

jsjavascript
window.addEventListener("message", (event) => {
  const msg = event.data;
  if (msg?.source !== "underlayer") return;

  switch (msg.event) {
    case "course.viewed":
      break;
    case "course.progress":
      // msg.data: { percent, screenIndex, screenId, screenTitle, totalScreens }
      break;
    case "course.answered":
      // msg.data: { blockId, correct, screenId, screenTitle, blockType, response }
      break;
    case "course.completed":
      // msg.data: { correct, total, answers: [{ blockId, correct }] }
      unlockNextModule();
      break;
  }
});
course.viewed

Fires once when the learner opens the course.

course.progress

Fires on mount and on every screen change, not just at the end.

course.answered

Fires per graded block, with the learner's raw response and whether it was correct.

course.completed

Fires once, the first time the learner reaches the last screen, with the final score.

Certificates

When a course has a passing score and the learner passes it, the player offers a PDF certificate at /api/certificate?courseId=…&identityId=…. It is public in the same sense the embed is — no session — but every fact on it (name, course, date, score, pass/fail) is re-derived server-side from the stored completion. There is no way to request a certificate for a course that was never actually completed or passed, whatever you put in the query string.

What the PDF looks like comes from the certificate template assigned to the course — issuer, logo, wording, colors, border and orientation, designed under Certificates in the dashboard or through the REST API. Courses with none assigned use the built-in default. Templates support Arabic and right-to-left layout with bundled Arabic typefaces, vector corner ornaments and seals, and background, seal and signature images.

Every certificate carries a serial — UL-34YT-T22D-M7BQ — printed on the PDF along with the URL that checks it. Anyone holding the document can open https://underlayerhq.com/verify/<serial> with no account and no key and see the issuer, the course, the recipient’s name, the completion and issue dates and the score. That page never shows the learner’s email. Without a serial, a certificate is a PDF anyone could have typed; that is most of what a certificate is for.

Issuing is idempotent. A learner downloading twice gets the same serial, the certificate.issued webhook fires exactly once, and you can list everything issued at GET /v1/issued-certificates. Certificates from a Sandbox workspace are watermarked.

Not using an iframe?

If your learners live inside an LMS rather than your own app, export the course as a SCORM package instead — same player, no iframe, and progress reports through the LMS's own tracking. See SCORM.