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://underlayer.outworx.io/embed/COURSE_ID?identity=usr_8f2k&lang=ar"
  width="100%"
  height="600"
  style="border:0"
></iframe>

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.

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.

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://underlayer.outworx.io/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.