Skip to main content

Developers

Webhooks

HMAC-signed webhooks for ten course, learner and organization events, with per-event payloads, signature verification, delivery behavior and delivery logs.

Last reviewed

Scormbusters sends HTTP POST requests to your configured endpoints when course lifecycle events occur. Use webhooks to sync completion data to your HR system, trigger Slack notifications, update a data warehouse, or integrate with any external service.


Table of contents

  1. Setting up a webhook
  2. Security — verifying signatures
  3. Payload envelope
  4. Events reference
  5. Delivery behavior
  6. Delivery logs

Setting up a webhook

Settings → API Keys & Webhooks (owner or admin required)

  1. Click Add webhook
  2. Enter your endpoint URL (https://your-server.com/hooks/scormbusters)
  3. Select the events you want to receive
  4. Copy the generated signing secret — it's shown once

Your endpoint must:

  • Accept POST requests
  • Return a 2xx status code within 10 seconds
  • Be reachable from the internet (localhost won't work in production)

Security — verifying signatures

Every request includes an X-OpenCourse-Signature header with an HMAC-SHA256 signature of the raw request body, using your webhook's signing secret.

Always verify this signature before processing the payload. Without verification, anyone who discovers your endpoint URL can send fake events.

Verification examples

Node.js / TypeScript:

import crypto from "crypto";

function verifyWebhook(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// In your Express handler:
app.post("/hooks/scormbusters", express.raw({ type: "*/*" }), (req, res) => {
  const sig = req.headers["x-opencourse-signature"] as string;
  if (!verifyWebhook(req.body.toString(), sig, process.env.WEBHOOK_SECRET!)) {
    return res.status(401).send("Invalid signature");
  }
  const event = JSON.parse(req.body.toString());
  // process event...
  res.sendStatus(200);
});

Python:

import hmac, hashlib

def verify_webhook(raw_body: bytes, signature: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature, expected)

Payload envelope

All events share the same outer envelope:

{
  "event": "enrollment.completed",
  "timestamp": "2026-04-21T14:23:00.000Z",
  "data": {
    // event-specific fields
  }
}
FieldTypeDescription
eventstringEvent type (see below)
timestampISO 8601 stringWhen the event was generated
dataobjectEvent-specific payload

Events reference

`course.published`

Fired when a course status changes to "published".

{
  "event": "course.published",
  "timestamp": "2026-04-21T14:00:00.000Z",
  "data": {
    "courseId": "uuid",
    "status": "published"
  }
}

`enrollment.created`

Fired when a learner enrolls in a course.

{
  "event": "enrollment.created",
  "timestamp": "2026-04-21T14:05:00.000Z",
  "data": {
    "courseId": "uuid",
    "userId": "uuid"
  }
}

`lesson.completed`

Fired each time a learner completes a lesson.

{
  "event": "lesson.completed",
  "timestamp": "2026-04-21T14:10:00.000Z",
  "data": {
    "courseId": "uuid",
    "lessonId": "uuid",
    "userId": "uuid",
    "score": 85,
    "maxScore": 100
  }
}

score and maxScore are null if the lesson has no scored content.


`enrollment.completed`

Fired when a learner completes all lessons in a course (via the learner player) or when a completion is recorded directly via the API.

{
  "event": "enrollment.completed",
  "timestamp": "2026-04-21T14:20:00.000Z",
  "data": {
    "courseId": "uuid",
    "userId": "uuid",
    "status": "passed",
    "score": 85,
    "maxScore": 100
  }
}
status valueMeaning
"passed"Learner met the course's passing score threshold
"failed"Learner completed all lessons but did not meet the passing score
"completed"Learner completed all lessons; course has no scored content

score and maxScore are null if the course has no scored content.


`course.unpublished`

Fired when a published course is taken back to draft (or archived). It fires only on a real transition — republishing an already-published course emits nothing, and archiving a draft is not an unpublish.

{
  "event": "course.unpublished",
  "timestamp": "2026-10-03T09:00:00.000Z",
  "data": {
    "courseId": "uuid",
    "status": "draft"
  }
}

`quiz.submitted`

Fired when the server grades a learner's answer to a question block. One event per graded submission, including retries (see attemptNumber).

{
  "event": "quiz.submitted",
  "timestamp": "2026-10-03T09:05:00.000Z",
  "data": {
    "courseId": "uuid",
    "lessonId": "uuid",
    "blockId": "uuid",
    "userId": "uuid",
    "isCorrect": true,
    "score": 1,
    "maxScore": 1,
    "attemptNumber": 1
  }
}

`certificate.issued`

Fired when a learner first earns a certificate: they pass a course that has certificates enabled. A repeat pass does not fire it again, and a later failed attempt never revokes it (completions keep the best result).

{
  "event": "certificate.issued",
  "timestamp": "2026-10-03T09:10:00.000Z",
  "data": {
    "courseId": "uuid",
    "userId": "uuid",
    "certificateId": "uuid",
    "score": 92
  }
}

certificateId is the id behind the public /verify/<certificateId> page.


`user.joined` and `invite.accepted`

Both fire when someone accepts an organization invite; invite.accepted is the invite-centric view, user.joined the member-centric one.

{
  "event": "user.joined",
  "timestamp": "2026-10-03T09:15:00.000Z",
  "data": {
    "userId": "uuid",
    "email": "person@example.com",
    "role": "member"
  }
}
{
  "event": "invite.accepted",
  "timestamp": "2026-10-03T09:15:00.000Z",
  "data": {
    "userId": "uuid",
    "role": "member"
  }
}

`user.removed`

Fired when an owner or admin removes a member from the organization.

{
  "event": "user.removed",
  "timestamp": "2026-10-03T09:20:00.000Z",
  "data": {
    "userId": "uuid",
    "role": "member"
  }
}

Delivery behavior

  • Webhooks are fired fire-and-forget — they don't block the learner's request
  • Timeout: 10 seconds per delivery attempt
  • No automatic retry on failure — failed deliveries are logged (see below) but not retried
  • If your endpoint returns a non-2xx status, the delivery is logged as failed
If you need guaranteed delivery, log the incoming webhook immediately and process it asynchronously. Return 200 as fast as possible.

Delivery logs

Settings → API Keys & Webhooks → View deliveries

Each delivery log shows:

  • Event type
  • Timestamp
  • Response status from your endpoint
  • Response body (first 500 characters)
  • Duration in milliseconds
  • Success / failure

Use delivery logs to debug endpoint issues or confirm that events are being received.