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.
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.
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:
| Tab | Purpose |
|---|---|
| Add New | Register a new endpoint |
| Endpoints | Edit, 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:
| Field | Description |
|---|---|
| Name | Internal label for the endpoint. Required. |
| Endpoint URL | The URL that receives the POST. Required. Must be publicly reachable. |
| Enabled | Check Send webhook deliveries to this endpoint to activate it. Deliveries to a disabled endpoint are stopped and marked disabled. |
| Events | Choose 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.
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
| Event | Fires when |
|---|---|
course.completed | A learner's course status transitions to passed |
course.failed | A learner's course status transitions to failed |
course.retake_started | A learner's course attempt is reset for a retake |
group.completed | Every course in a group reaches a completed status for a learner |
group.retake_started | All course attempts inside a group are reset |
certificate.awarded | A course certificate is awarded |
group_certificate.awarded | A group certificate is awarded |
enrollment.created | A learner is enrolled in a course or group |
Request format
Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | MentorKit LMS Webhooks/<plugin version> |
X-MentorKit-Event | The event type, e.g. course.completed |
X-MentorKit-Delivery | The unique event ID — use this for idempotency |
X-MentorKit-Timestamp | Unix timestamp used when computing the signature |
X-MentorKit-Signature-256 | sha256=<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/302response 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": {}
}
| Field | Description |
|---|---|
id | Unique event ID. Matches X-MentorKit-Delivery. Stable across retries — use it to deduplicate. |
type | Event type from the table above. |
version | Payload format version. Currently 1.0. |
created_at_gmt | When the event was generated, in GMT (Y-m-d H:i:s). |
site.url | Home URL of the LMS that sent the event. |
site.name | Site title of the LMS. |
data | Event-specific data — see below. |
Only fields suffixed _gmt are GMT. Timestamps inside data — completed_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
}
}
}
| Field | Description |
|---|---|
user.id | WordPress user ID in the LMS. Stable identifier for the learner. |
user.email | Learner's email address. Use this to match the learner against an external system. |
user.display_name | Display name. |
user.first_name, user.last_name | From the user profile. Empty strings if not filled in. |
course.id | Course post ID. Stable identifier for the course. |
course.type | Always course for this event. |
course.title | Course title at the time of the event. |
course.url | Permalink to the course. |
tracking.attempt_id | ID of the learner's tracking row. Changes after a retake, so it scopes the attempt. |
tracking.previous_status | Status before the transition, or null if there was none. |
tracking.status | passed for course.completed, failed for course.failed. |
tracking.score | Score 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.
| Event | data keys |
|---|---|
course.retake_started | user, course |
group.completed | user, group, tracking |
group.retake_started | user, group, course_ids (array of course IDs) |
certificate.awarded | user, course, certificate, certificate_data |
group_certificate.awarded | user, group, certificate, certificate_data |
enrollment.created | user, 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:
| Attempt | Delay after previous attempt |
|---|---|
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
| 6 | 24 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.testevent 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_payloadfilter for customizing the payload before it is queued - Hook Documentation Overview — how the hook reference pages are organized