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
- Setting up a webhook
- Security — verifying signatures
- Payload envelope
- Events reference
- Delivery behavior
- Delivery logs
Setting up a webhook
Settings → API Keys & Webhooks (owner or admin required)
- Click Add webhook
- Enter your endpoint URL (
https://your-server.com/hooks/scormbusters) - Select the events you want to receive
- Copy the generated signing secret — it's shown once
Your endpoint must:
- Accept
POSTrequests - Return a
2xxstatus 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
}
}| Field | Type | Description |
|---|---|---|
event | string | Event type (see below) |
timestamp | ISO 8601 string | When the event was generated |
data | object | Event-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 value | Meaning |
|---|---|
"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.