DocsDeliveringEmbedded builder

Embedded builder

The authoring surfaces, in your product rather than ours — the course builder and the certificate designer. One signed iframe URL each, and your own app decides who may edit what.

What it is

Embedding puts the player inside your product. This puts the editor there too — the same canvas, rail and inspector your team uses in the dashboard, rendered inside your page, under your domain, for a user who has never heard of us and has no account here.

It is for the case where your users write their own courses. Without it, they have to be sent to our dashboard and given a login, which means your product has a seam in it and your onboarding has a second account in it.

There are two of them, and everything on this page applies to both: the course builder at /embed/builder/{course_id}, and the certificate designer at /embed/certificate/{certificate_id}. Same secret, same switch, same session behaviour. Where they differ is gathered under the certificate designer.

Turning it on

The embedded builder works on every plan, the free sandbox included — you should be able to put it in your own product and show it to somebody before you pay for it. A sandbox is bounded by the limits it already has rather than by a locked door: 3 courses, 30 screens each.

Two things on the API keys page of the dashboard, both owner/admin only:

  1. Add the origins allowed to frame the builder — https://app.example.com, one per line, including staging. Anything not listed is refused by the browser, not by us.
  2. Leave the switch alone unless you want it off. It is on by default; turning it off is how you stop every live session at once, and it is re-read on every save rather than only at page load.

The origins list is the gate, not the switch. It starts empty, and an empty list means nobody may frame the builder — not “allow all”, which is the reading that turns a forgotten setup step into an open door. So until you add a domain, the editor is on and frames nowhere.

The builder URL

The editor for one course is served at /embed/builder/{course_id}. Unlike the player it has no unsigned mode — every parameter below is required, every time.

html
<iframe
  src="https://underlayer.outworx.io/embed/builder/COURSE_ID?actor=usr_8f2k&scope=edit&expires=1767225600&signature=9c1e…"
  width="100%"
  height="800"
  style="border:0"
  allow="clipboard-write"
></iframe>

Give it real height. The builder is a three-pane application, not a card — below about 700px the inspector and the rail start fighting over the same space.

Query parameters

actor

Your own id for the person editing. It is recorded against what they change, and it is inside the signature, so a user cannot become another user by editing the URL.

scope

edit or publish. See scopes.

expires

Unix time in seconds after which the URL stops working. At most 60 minutes ahead — far shorter than the player's, because this one is write access rather than a learner's own progress.

signature

Hex HMAC-SHA256 of builder.{actor}.{scope}.{expires}, keyed with your workspace's embed signing secret — the same secret the player uses.

lang

Optional, and not the same lang the player takes. On the player it picks a translation of the course; here it picks the language of the editor's own interface — its menus, labels and buttons — so an Arabic-speaking author edits in Arabic. The course's content is whatever is in the course. An unknown or missing value falls back to English, and a right-to-left language lays the editor out right-to-left.

Signing the URL

Your backend already knows who the user is and which courses they may touch. It says so by signing, and we believe the signature rather than the query string. Sign on the server when you render the page, never in the browser — the secret is an API key by another name.

nodejavascript
import { createHmac } from "crypto";

function builderUrl(courseId, userId, scope = "edit") {
  const expires = Math.floor(Date.now() / 1000) + 10 * 60; // good for 10 minutes
  const signature = createHmac("sha256", process.env.UNDERLAYER_EMBED_SECRET)
    .update(`builder.${userId}.${scope}.${expires}`)
    .digest("hex");

  const params = new URLSearchParams({ actor: userId, scope, expires, signature });
  return `https://underlayer.outworx.io/embed/builder/${courseId}?${params}`;
}

The scope is inside the signed message, not beside it. Sign an edit link and nobody can turn it into a publish one by editing the address bar.

Scopes

edit

Everything the canvas does: screens, blocks, media, fonts, the course's own settings. The publish button is not rendered.

publish

Everything edit does, plus publishing and unpublishing. Worth separating, because publishing is the one action whose effect leaves your workspace and reaches every learner already inside the course.

Sessions and expiry

The URL signature is checked once, when the page loads. After that the builder holds a session token of our own, which every save carries — so the URL can expire in ten minutes without cutting an author off mid-sentence.

That token is good for 1 hour at a time and the builder renews it in the background while somebody is editing, including when a tab is brought back to the front after being left. Renewal re-runs the same checks every save runs, so a session cannot renew its way past a workspace that has been switched off, downgraded, or suspended — and it cannot renew forever either: the session's start is inside the signed token, so renewals stop 8 hours after it began. Past that the author is sent back to your app to be re-authorised, which is where that decision belongs.

You do not have to do anything for any of this — except listen for builder.session.ended, which is how the frame tells you it needs a fresh URL.

The token lives in memory and dies with the tab. It is never a cookie — a cookie is not sent to a third-party frame in Safari at all, and partitioned cookies only close that gap on Chrome and Firefox — so nothing about this depends on the browser's third-party cookie policy. Rotating the embed secret invalidates every live session immediately.

Limits

Changes made through the embedded builder are rate limited to 120 a minute per workspace. That is far above what editing looks like — the canvas autosaves on a debounce, and a person moving quickly across several screens does not approach it. It is there to stop a loop in an integration, not to pace an author. Over it, a save is refused with a message saying how long to wait, and the author can carry on when it clears; nothing is lost.

A rate-limited save is not the end of a session, and does not emit builder.session.ended. The credential is still good; only that one write was turned away. Do not mint a new URL on it — wait and let the author try again.

Your plan's own limits apply exactly as they do in the dashboard — the embedded builder is the same editor, not a way around them. The one it enforces directly is screens per course: 30 on the sandbox, unlimited on every paid plan. A course that is already over its limit stays fully editable, because editing your way back down is the way out.

Events

The builder posts a message to your page every time something is saved, so you can keep your own record of what changed — a revision note, an audit row, a “last edited by” in your own UI — without polling us or waiting on a webhook.

Every event fires after the write came back clean, so anything you receive describes something that is already in the database. Messages carry source: "underlayer" — the same envelope the player's course.* events use, so one listener can serve both and tell them apart by prefix.

jsjavascript
window.addEventListener("message", (e) => {
  if (e.origin !== "https://underlayer.outworx.io") return;
  if (e.data?.source !== "underlayer") return;
  if (!e.data.event.startsWith("builder.")) return;

  const { event, courseId, at, data } = e.data;
  saveToYourOwnStore({ event, courseId, at, ...data });

  if (event === "builder.session.ended") {
    // Mint a fresh signed URL and reload the frame.
    refreshBuilderFrame();
  }
});

Messages are addressed to the origins on your allowlist, not broadcast to "*" — the payload describes your course, so it goes only where you said the builder may be framed. Always check e.origin on your side too.

builder.ready

The canvas is up and usable. Carries actor and scope. A loading iframe and a refused one look identical from outside, so this is what to hide your own spinner on.

builder.screen.saved

A screen was saved — the autosave, so this is the frequent one. Carries screenId, title and blockCount.

builder.screen.added

Carries screenId and the templateKey it was seeded from, if any.

builder.screen.duplicated

Carries the new screenId and the sourceScreenId it was copied from.

builder.screen.deleted

Carries the screenId that is gone.

builder.screens.reordered

Carries screenIds, the full new order.

builder.course.updated

A course-level setting changed. Carries setting — one of direction, navigation, swipe, passingScore, quizFeedback — and its new value.

builder.course.published

Only reachable with the publish scope. The builder.course.unpublished counterpart fires the other way.

builder.session.ended

A save was refused because the session expired or the workspace turned the builder off. Your cue to mint a fresh URL and reload the frame rather than leave somebody typing into a dead canvas.

If you render the builder outside an iframe, the same payloads are dispatched on window as an underlayer:builder CustomEvent, with the message on event.detail.

The certificate designer

The certificate designer embeds the same way, at /embed/certificate/{certificate_id}, signed by the same secret and turned on by the same switch. Everything above applies — the actor, the expiry, the sliding session, the rate limit, the events — with three differences.

certificate.

The signed message uses this prefix instead of builder., so it is certificate.{actor}.{scope}.{expires}. A URL signed for a course will not open a certificate and the reverse is also refused — the two are separate grants, not one grant pointed at two things.

scope=edit

The only scope a certificate takes. A template is live from the moment a course points at it, so there is nothing to publish, and accepting the word would mean granting nothing while sounding like it granted something. scope=publish is refused.

certificate.*

Its own events — certificate.ready, certificate.design.saved, certificate.renamed, certificate.logo.uploaded, certificate.serial.updated and certificate.session.ended. Same envelope, and each message carries kind plus certificateId rather than courseId, so one listener can route on kind without parsing event names.

A session is scoped to one template by id, exactly as a course session is scoped to one course. That is deliberate and it is the reason this is safe to embed: a certificate carries your issuer name and logo and is printed onto a document a learner keeps, so “may design our certificates” is not a permission this route can express. If you want one of your users editing three templates, sign three URLs.

html
<iframe
  src="https://underlayer.outworx.io/embed/certificate/CERTIFICATE_ID?actor=usr_8f2k&scope=edit&expires=1767225600&signature=9c1e…"
  width="100%"
  height="900"
  style="border:0"
></iframe>

Give it more height than the course builder, not less. The designer is a rail, a live PDF preview and a panel of words side by side, and the preview is a page of A4 — below about 800px it is being judged at a size nobody will ever read it at.

nodejavascript
import { createHmac } from "crypto";

function certificateUrl(certificateId, userId) {
  const expires = Math.floor(Date.now() / 1000) + 10 * 60; // good for 10 minutes
  const signature = createHmac("sha256", process.env.UNDERLAYER_EMBED_SECRET)
    .update(`certificate.${userId}.edit.${expires}`)
    .digest("hex");

  const params = new URLSearchParams({ actor: userId, scope: "edit", expires, signature });
  return `https://underlayer.outworx.io/embed/certificate/${certificateId}?${params}`;
}

Sending a test email and deleting the template are both absent — the first lands in a dashboard user's own inbox, which an embedded author does not have, and the second reaches every course pointing at that template.

The live preview inside the designer is a real PDF rendered by the same code that issues certificates, so what an author sees is what a learner receives. It is fetched with the session token on the URL, which is why you will see one there if you look — it is scoped to that one template and expires with the session, like every other use of it.

What the embedded builder leaves out

Three things the dashboard's builder has that this one does not, each because it leads somewhere outside the iframe or does something an embedded editor should not:

  • Deleting the course. There is no embedded path to it at all, whatever the scope.
  • SCORM export and Translations. Both are pages elsewhere in the dashboard, and a link out of an iframe either goes nowhere useful or navigates your own product away from itself.
  • Creating a certificate. Choosing which certificate the course issues is there, like choosing its theme — only the link to go and design a new one is dropped, for the reason above.

When it does not appear

A blank iframe with a console message about frame-ancestors means the origin is not on the list — that refusal is the browser's, so nothing reaches us and there is nothing in your server logs.

Anything else renders a short explanation in the frame rather than failing silently, and repeats it on the wrapper as data-builder-refused so your own tooling can read the reason without scraping our copy. The values are disabled, expired, invalid, notFound and billing.

invalid is almost always the signed message: it is builder. then actor, scope and expires joined by dots, and the expires in the signature has to be the same string you put in the URL.