Skip to main content

Webhooks

Send MentorKit LMS events — course completions, certificates, enrollments — to an external system in real time. This guide covers how to configure an endpoint, what the delivered data package looks like, and how the receiving system should verify and acknowledge it.

Prerequisites

You need the LMS Manager capability (manage_scorm_courses) to configure webhooks, and a receiving endpoint that can accept HTTPS POST requests with a JSON body.

Feature status

Webhooks are marked Beta in the admin UI. The payload envelope is versioned (version: "1.0"), so a receiving system can branch on the version if the format is extended later.

Overview

Go to MentorKit → Webhooks. The page has two tabs:

TabPurpose
Add NewRegister a new endpoint
EndpointsEdit, test, or delete existing endpoints, and view the Recent Deliveries log

When a subscribed event occurs, MentorKit builds a JSON payload, queues one delivery per matching endpoint, and sends it via a background job. Delivery is asynchronous — the learner never waits for your system to respond.

Registering an endpoint

Fill in the form under MentorKit → Webhooks → Add New:

FieldDescription
NameInternal label for the endpoint. Required.
Endpoint URLThe URL that receives the POST. Required. Must be publicly reachable.
EnabledCheck Send webhook deliveries to this endpoint to activate it. Deliveries to a disabled endpoint are stopped and marked disabled.
EventsChoose All events to subscribe to everything, or Selected events and tick the individual events below.

Click Add Endpoint. A signing secret (prefixed whsec_) is generated automatically and shown on the endpoint card under Endpoints.

tip

Use Selected events rather than All events when the receiving system only cares about completions. Every event you subscribe to becomes a delivery attempt with its own retry schedule.

Available events

EventFires when
course.completedA learner's course status transitions to passed
course.failedA learner's course status transitions to failed
course.retake_startedA learner's course attempt is reset for a retake
group.completedEvery course in a group reaches a completed status for a learner
group.retake_startedAll course attempts inside a group are reset
certificate.awardedA course certificate is awarded
group_certificate.awardedA group certificate is awarded
enrollment.createdA learner is enrolled in a course or group

Request format

Each delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentMentorKit LMS Webhooks/<plugin version>
X-MentorKit-EventThe event type, e.g. course.completed
X-MentorKit-DeliveryThe unique event ID — use this for idempotency
X-MentorKit-TimestampUnix timestamp used when computing the signature
X-MentorKit-Signature-256sha256=<hex digest> — see below

Your endpoint must:

  • Respond with a 2xx status code. Anything else counts as a failed attempt and is retried.
  • Respond within 15 seconds. Do the real processing asynchronously if it takes longer.
  • Serve the payload URL directly — redirects are not followed. A 301/302 response is treated as a failure.

Verifying the signature

The signature is an HMAC-SHA256 over the timestamp, a literal dot, and the raw request body, keyed with the endpoint's secret:

signature = HMAC_SHA256(secret, timestamp + "." + raw_body)

Compare it against the hex digest in X-MentorKit-Signature-256 (after the sha256= prefix), using a constant-time comparison. Always sign the raw body bytes — re-serializing the parsed JSON produces a different digest.

$timestamp = $_SERVER['HTTP_X_MENTORKIT_TIMESTAMP'];
$raw_body = file_get_contents( 'php://input' );
$expected = hash_hmac( 'sha256', $timestamp . '.' . $raw_body, $secret );
$received = substr( $_SERVER['HTTP_X_MENTORKIT_SIGNATURE_256'], strlen( 'sha256=' ) );

if ( ! hash_equals( $expected, $received ) ) {
http_response_code( 401 );
exit;
}
const crypto = require("crypto");

const expected = crypto
.createHmac("sha256", secret)
.update(`${req.headers["x-mentorkit-timestamp"]}.${rawBody}`)
.digest("hex");

const received = req.headers["x-mentorkit-signature-256"].replace("sha256=", "");
const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));

Reject requests whose timestamp is far from the current time to limit replay of captured deliveries.

Payload envelope

Every event shares the same envelope. Only data differs per event type.

{
"id": "evt_2f1c9a7b4d3e4f118a6c0b5d2e7f9a10",
"type": "course.completed",
"version": "1.0",
"created_at_gmt": "2026-08-04 09:14:22",
"site": {
"url": "https://learning.yourcompany.com",
"name": "Acme Corp Learning Portal"
},
"data": {}
}
FieldDescription
idUnique event ID. Matches X-MentorKit-Delivery. Stable across retries — use it to deduplicate.
typeEvent type from the table above.
versionPayload format version. Currently 1.0.
created_at_gmtWhen the event was generated, in GMT (Y-m-d H:i:s).
site.urlHome URL of the LMS that sent the event.
site.nameSite title of the LMS.
dataEvent-specific data — see below.
Time zones

Only fields suffixed _gmt are GMT. Timestamps inside datacompleted_at on a group, awarded_date on a certificate — use the LMS site's configured local time zone. Convert them using the site's time zone, not UTC.

course.completed

The data package sent when a learner passes a course. course.failed uses the identical shape, with tracking.status set to failed.

{
"id": "evt_2f1c9a7b4d3e4f118a6c0b5d2e7f9a10",
"type": "course.completed",
"version": "1.0",
"created_at_gmt": "2026-08-04 09:14:22",
"site": {
"url": "https://learning.yourcompany.com",
"name": "Acme Corp Learning Portal"
},
"data": {
"user": {
"id": 412,
"email": "anne@yourcompany.com",
"display_name": "Anne Example",
"first_name": "Anne",
"last_name": "Example"
},
"course": {
"id": 87,
"type": "course",
"title": "Working at Heights",
"url": "https://learning.yourcompany.com/courses/working-at-heights/"
},
"tracking": {
"attempt_id": 5391,
"previous_status": "attempted",
"status": "passed",
"score": 92
}
}
}
FieldDescription
user.idWordPress user ID in the LMS. Stable identifier for the learner.
user.emailLearner's email address. Use this to match the learner against an external system.
user.display_nameDisplay name.
user.first_name, user.last_nameFrom the user profile. Empty strings if not filled in.
course.idCourse post ID. Stable identifier for the course.
course.typeAlways course for this event.
course.titleCourse title at the time of the event.
course.urlPermalink to the course.
tracking.attempt_idID of the learner's tracking row. Changes after a retake, so it scopes the attempt.
tracking.previous_statusStatus before the transition, or null if there was none.
tracking.statuspassed for course.completed, failed for course.failed.
tracking.scoreScore recorded for the attempt, as reported by the course package.

Only the user.id and course.id keys are guaranteed present. The remaining user and course fields are omitted if the user or post no longer exists when the event is built — write your parser defensively.

course.completed and course.failed are deduplicated for 24 hours per learner, course, attempt, and status, so a repeated transition inside the same attempt does not produce a second delivery.

Other event payloads

All events wrap user and post objects in the same shape as above.

Eventdata keys
course.retake_starteduser, course
group.completeduser, group, tracking
group.retake_starteduser, group, course_ids (array of course IDs)
certificate.awardeduser, course, certificate, certificate_data
group_certificate.awardeduser, group, certificate, certificate_data
enrollment.createduser, target (a course or group object), source, context

For group.completed, the tracking object holds the group progress summary:

{
"total_courses": 6,
"completed_courses": 6,
"in_progress_courses": 0,
"progress_percentage": 100,
"status": "completed",
"completed_at": "2026-08-04 11:14:22"
}

For the certificate events, certificate_data holds:

{
"certificate_id": 233,
"course_id": 87,
"user_id": 412,
"score": 92,
"awarded_date": "2026-08-04 11:14:23",
"awarded_timestamp": 1785928463,
"expires_timestamp": 1817464463,
"expires_at": "2027-08-04 11:14:23"
}

expires_timestamp and expires_at appear only when the course or group has an expiration policy enabled. A manually issued certificate additionally carries issued_manually: true and issued_by (the user ID of the issuer).

For enrollment.created, source identifies where the enrollment came from — admin, woocommerce, or a frontend source key — and context carries extra data for that source (for WooCommerce, order_id and product_id).

Retries and the delivery log

A delivery that does not return 2xx is retried on a fixed backoff:

AttemptDelay after previous attempt
25 minutes
330 minutes
42 hours
512 hours
624 hours

After the final attempt the delivery is marked failed and is not retried again. Because retries reuse the same event ID, the receiving system must treat X-MentorKit-Delivery as an idempotency key and ignore an event it has already processed.

MentorKit → Webhooks → Endpoints shows the last 25 delivery attempts under Recent Deliveries, with the event type, status (pending, success, retrying, failed, disabled), attempt count, and the HTTP response code or error message returned by the endpoint.

Testing an endpoint

On the endpoint card under Endpoints:

  • Send Test — delivers a webhook.test event immediately, regardless of which events the endpoint subscribes to. Use it to confirm connectivity and signature verification before going live.
  • Regenerate Secret — issues a new signing secret. Deliveries signed with the old secret will fail verification, so update the receiving system at the same time.
  • Delete — removes the endpoint. Queued deliveries to it are marked disabled.

The test payload uses the standard envelope with type: "webhook.test" and data: { "message": "This is a test webhook from MentorKit LMS." }.

What's Next

  • Webhook Hooks — the WordPress actions and the scorm_player_webhook_payload filter for customizing the payload before it is queued
  • Hook Documentation Overview — how the hook reference pages are organized