DocsThe APIWebhooks

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فتح متعلم دورة منشورة.
  • course.progressانتقل متعلم إلى شاشة جديدة — يُطلق عند كل تغيير شاشة.
  • course.answeredأجاب متعلم عن كتلة اختبار، بشكل صحيح أو خاطئ.
  • course.completedوصل متعلم إلى الشاشة الأخيرة، مع درجته النهائية.
  • course.publishedأصبح رابط تضمين الدورة نشطًا.
  • course.unpublishedتم إيقاف رابط تضمين الدورة.
  • certificate.issuedنزّل متعلم شهادته لأول مرة، مع الرقم التسلسلي للتحقق منها.
  • identity.createdظهر متعلم لأول مرة (يدويًا أو عبر الواجهة أو من التضمين).

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:

POST your-endpointjson
{
  "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.viewedjson
{
  "course": { "id": "c_123", "title": "Onboarding 101" },
  "identity": { "id": "i_456", "externalId": "usr_8f2k", "name": "Jane Doe", "email": "jane@company.com" }
}
course.progressjson
{
  "course": { "id": "c_123", "title": "Onboarding 101", "totalScreens": 11 },
  "screen": { "id": "s_intro", "title": "Welcome", "index": 0 },
  "percent": 9,
  "identity": { ... }
}
course.answeredjson
{
  "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.completedjson
{
  "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.

certificate.issuedjson
{
  "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.publishedjson
{
  "course": { "id": "c_123", "title": "Onboarding 101" },
  "embedUrl": "https://underlayer.outworx.io/embed/c_123"
}

course.unpublished carries the same shape.

identity.createdjson
{
  "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):

headershttp
X-Underlayer-Event: course.completed
X-Underlayer-Delivery: evt_9f3k...
X-Underlayer-Signature: sha256=<hex digest>
nodejavascript
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.