Webhooks
Register an endpoint and get an HTTP POST the moment a learner does something — no polling.
Registering an endpoint
Add one from your workspace's Webhooks page, or manage endpoints programmatically through the REST API. Either way, the signing secret is shown exactly once, at creation.
Events
Pick which of these to subscribe to:
course.viewed— A learner opened a published course.course.progress— A learner moved to a new screen — fires on every screen change.course.answered— A learner answered a quiz block, right or wrong.course.completed— A learner reached the last screen, with their final score.course.published— A course's embed link went live.course.unpublished— A course's embed link went offline.certificate.issued— A learner downloaded their certificate for the first time, with its verification serial.identity.created— A learner was seen for the first time (manual, API, or embed view).
What doesn't fire one
Every event above is learner or course lifecycle. Nothing fires when an AI generation job finishes — that is the one long-running operation in the product, and today it is polled rather than pushed. Don’t wait on an event for it.
Payload
Every delivery is the same JSON envelope. Only data changes shape, and it changes with type:
{
"id": "evt_9f3k...",
"type": "course.completed",
"createdAt": "2026-08-01T00:00:00.000Z",
"workspaceId": "w_456",
"data": { ... }
}identity appears on every learner event and is null for anonymous runs — an embed opened without an ?identity= still teaches someone, it just can’t say who.
Payload by event
{
"course": { "id": "c_123", "title": "Onboarding 101" },
"identity": { "id": "i_456", "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" }
}{
"course": { "id": "c_123", "title": "Onboarding 101", "totalScreens": 11 },
"screen": { "id": "s_intro", "title": "Welcome", "index": 0 },
"percent": 9,
"identity": { ... }
}{
"course": { "id": "c_123", "title": "Onboarding 101" },
"screen": { "id": "s_quiz", "title": "Quiz: core concepts" },
"block": { "id": "b_q1", "type": "quiz_single_choice" },
"correct": true,
"identity": { ... }
}{
"course": { "id": "c_123", "title": "Onboarding 101" },
"identity": { ... },
"score": { "correct": 8, "total": 10, "percent": 80 },
"passed": true,
"answers": [{ "blockId": "b_q1", "correct": true }],
"startedAt": "2026-08-01T09:12:00.000Z",
"completedAt": "2026-08-01T09:18:52.000Z",
"timeSpentSeconds": 412
}passed is measured against the course’s passingScore, and is null when the course has none — so any completion counts. It is included so a subscriber unlocking something on completion doesn’t have to fetch the course, read its passing score and reimplement the comparison, then disagree with us the moment the rule changes.
{
"course": { "id": "c_123", "title": "Onboarding 101" },
"identity": { ... },
"serial": "UL-34YT-T22D-M7BQ",
"verifyUrl": "https://underlayer.outworx.io/verify/UL-34YT-T22D-M7BQ",
"score": 80,
"issuedAt": "2026-08-01T09:19:04.000Z"
}Fires the first time a learner downloads their certificate and never again for the same course and learner — it announces a credential coming into existence, not a download, and one achievement must not look like several because someone saved the PDF twice. The verifyUrl is public: anyone can open it, with no account and no key.
{
"course": { "id": "c_123", "title": "Onboarding 101" },
"embedUrl": "https://underlayer.outworx.io/embed/c_123"
}course.unpublished carries the same shape.
{
"identity": { "id": "i_456", "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" }
}Verifying signatures
Every request carries an HMAC-SHA256 signature of the raw request body, signed with the endpoint's own secret (shown once when you create it):
X-Underlayer-Event: course.completed
X-Underlayer-Delivery: evt_9f3k...
X-Underlayer-Signature: sha256=<hex digest>import { createHmac, timingSafeEqual } from "crypto";
function isValid(secret, rawBody, header) {
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}Retries
A delivery times out after 5 seconds. Anything other than a 2xx response is retried with backoff, up to 5 total attempts, after which it's marked failed — you can see the full delivery log and manually resend any attempt from the endpoint's dashboard page.