Webhooks
EmbayLMS sends webhook events to your server when things happen in your tenant — a user is enrolled, completes a course, a certificate is issued. Webhooks let your integrations react in real time without polling.
Plan requirement: webhooks share the API access entitlement (Growth and above). On plans without API access, existing endpoints are kept and can be deleted, but new ones cannot be registered and no deliveries are dispatched — delivery resumes automatically on upgrade.
Overview
When an event occurs, EmbayLMS sends an HTTP POST request to your configured endpoint with a JSON payload. Your server must return a 2xx status code within 10 seconds. If it does not, the delivery is retried (see Retry Policy).
Configuring Webhook Endpoints
- Sign in to your EmbayLMS tenant as an admin.
- Navigate to Settings -> Integrations -> Webhooks (the Webhooks tab of the unified Integrations menu).
- Click New Endpoint.
- Enter your endpoint URL (must be HTTPS).
- Select the events you want to receive.
- Click Create endpoint. EmbayLMS shows the endpoint’s signing secret (
whsec_…) — copy it immediately; it is shown only once.
Delivery Request
Each delivery is an HTTP POST with these headers:
| Header | Value |
|---|---|
content-type | application/json |
x-embaylms-event | The event type (e.g. enrollment.created) |
x-embaylms-delivery | The delivery UUID — use it for idempotency |
x-embaylms-signature | t=<unix-seconds>,v1=<hmac-sha256-hex> (see below) |
Retries of the same delivery send a byte-identical body (only the signature timestamp changes), so deduplicating on x-embaylms-delivery / the envelope id is safe.
Signature Verification
Every request carries an x-embaylms-signature header in the format:
x-embaylms-signature: t=1748995260,v1=3d9b2e1f4a5c6789abcdef0123456789abcdef0123456789abcdef0123456789t— Unix timestamp (seconds) at which the delivery attempt was signed.v1— hex HMAC-SHA256 of`${t}.${rawBody}`keyed with your webhook secret.
Always verify the signature before processing the payload, and reject requests whose timestamp is more than 5 minutes old (replay protection).
Node.js Verification Example
const crypto = require('crypto');
/**
* @param {string} header the x-embaylms-signature header value ("t=...,v1=...")
* @param {string|Buffer} rawBody the RAW request body (before JSON.parse)
* @param {string} secret your whsec_… webhook secret
* @param {number} toleranceSec max signature age in seconds (default 300)
*/
function verifyEmbayLmsSignature(header, rawBody, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
header.split(',').map((pair) => pair.split('=').map((s) => s.trim()))
);
const { t, v1 } = parts;
if (!t || !v1) throw new Error('Malformed signature header');
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const ok =
expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex'));
if (!ok) throw new Error('Invalid signature');
// Replay protection — reject stale timestamps AFTER the HMAC check.
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) {
throw new Error('Webhook timestamp outside tolerance');
}
}Use
crypto.timingSafeEqual— never compare signatures with===(timing attack vulnerability). Verify against the raw request body: parse JSON only after the signature check passes.
Retry Policy
Each delivery is attempted up to 5 times with exponential backoff starting at 30 seconds:
| Attempt | Delay after previous failure |
|---|---|
| 1 | immediate |
| 2 | ~30 seconds |
| 3 | ~1 minute |
| 4 | ~2 minutes |
| 5 | ~4 minutes |
A non-2xx response, a network error, or a response slower than 10 seconds counts as a failure. After the 5th failed attempt the delivery is marked failed and no further retries are made.
Inspecting and replaying deliveries
Every attempt is recorded. Open Settings → Integrations → Webhooks and click the delivery count on an endpoint to open its Deliveries drawer.
Each row shows:
| Field | Meaning |
|---|---|
| Status | Delivered, Failed, or Pending (enqueued, still inside the retry window) |
| Event | The event type, e.g. enrollment.created |
| HTTP status | What your endpoint replied, or “No response yet” |
| Attempts | How many of the 5 attempts have been made |
| Response body | Your endpoint’s reply, which is usually the actual reason a delivery failed |
Filter by status or by event to answer “what is broken” and “show me this event”
without paging. View payload shows the exact JSON envelope that was sent —
byte-identical to what your endpoint received, so you can replay it against your
own code. The signing secret never appears in a payload; it is only ever used to
compute the x-embaylms-signature header.
Retry now
Retry now on a delivery row queues it again through the same path a first-time delivery takes. Two things about it are worth knowing before you rely on it:
- It creates a new delivery, and the body is identical — envelope
idincluded. If you deduplicate on the envelopeidor thex-embaylms-deliveryheader (and you should), your endpoint will recognise the replay as the same event rather than processing it twice. - The original delivery is left exactly as it was. Its attempt history is the record of what happened; the replay appears as a separate row above it.
A retry into a deleted endpoint is refused rather than queued, because the worker would skip it and the refusal would look like a second failure. Register a new endpoint instead (it gets a new signing secret).
Retrying requires the permission that manages settings, and every retry is written to the audit log.
Best Practices
- Return 200 immediately. Process the event asynchronously — do not block the response on database writes or downstream API calls.
- Use the envelope
id(or thex-embaylms-deliveryheader) for idempotency. EmbayLMS may deliver the same event more than once (e.g., after a timeout where your server actually processed the request). Store processed ids and skip duplicates. - Use HTTPS only.
- Verify the signature on every request. Do not skip this step in production.
- Deleting an endpoint stops its deliveries — in-flight deliveries to a deleted (deactivated) endpoint are marked failed with
endpoint_inactive.
Webhook Payload Envelope
Every webhook body shares this top-level structure:
{
"id": "9b0c1d2e-3f4a-5678-bcde-678901234567",
"event": "enrollment.completed",
"createdAt": "2026-07-23T11:00:00.000Z",
"data": { }
}| Field | Type | Description |
|---|---|---|
id | UUID | Unique delivery ID. Use for idempotency. |
event | string | The event type (see catalog below) |
createdAt | ISO 8601 | When the event was recorded |
data | object | Event-specific payload |
Event Catalog
Eleven events are available today. Field values are internal UUIDs unless noted.
enrollment.created
Fired when a user is enrolled in a course — by self-enrollment, an admin, bulk enroll, an auto-enroll rule, learning-path progression, the REST API, or a storefront purchase. status is the initial enrollment status: active, pending_approval, or waitlisted.
{
"event": "enrollment.created",
"data": {
"enrollmentId": "d6e5f4a3-7b8c-9012-defa-123456789012",
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"courseId": "e7f6a5b4-8c9d-0123-efab-234567890123",
"status": "active"
}
}enrollment.completed
Fired when a learner completes every required module of a course.
{
"event": "enrollment.completed",
"data": {
"enrollmentId": "d6e5f4a3-7b8c-9012-defa-123456789012",
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"courseId": "e7f6a5b4-8c9d-0123-efab-234567890123",
"completedAt": "2026-07-23T11:00:00.000Z"
}
}enrollment.dropped
Fired when an enrollment is dropped (by the learner or an admin).
{
"event": "enrollment.dropped",
"data": {
"enrollmentId": "d6e5f4a3-7b8c-9012-defa-123456789012",
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"courseId": "e7f6a5b4-8c9d-0123-efab-234567890123"
}
}course.published
Fired when a course is published — manually or by a scheduled publish.
{
"event": "course.published",
"data": {
"courseId": "e7f6a5b4-8c9d-0123-efab-234567890123",
"title": "WHMIS 2015"
}
}course.archived
Fired when a course is archived — manually or by a scheduled unpublish.
{
"event": "course.archived",
"data": {
"courseId": "e7f6a5b4-8c9d-0123-efab-234567890123",
"title": "WHMIS 2015"
}
}user.created
Fired when a new user is created — by an admin, a CSV import, or SCIM provisioning. Not fired for the initial tenant-owner account created at signup.
{
"event": "user.created",
"data": {
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"email": "jane.doe@acme.com",
"role": "learner"
}
}user.deactivated
Fired when a user account is deactivated.
{
"event": "user.deactivated",
"data": {
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"email": "jane.doe@acme.com"
}
}user.reactivated
Fired when a deactivated user is re-hired (restored with their prior transcript intact).
{
"event": "user.reactivated",
"data": {
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"email": "jane.doe@acme.com"
}
}certificate.issued
Fired when a certificate is issued. Course certificates carry courseId; learning-path certificates carry pathId instead.
{
"event": "certificate.issued",
"data": {
"certificateId": "f8a7b6c5-9d0e-1234-fabc-345678901234",
"certificateNumber": "CERT-2026-A1B2C3",
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"courseId": "e7f6a5b4-8c9d-0123-efab-234567890123"
}
}job_role.assigned
Fired when a person receives a job role: assigned on their user page, or added by PUT /api/v1/users/{userId}/job-roles. source is manual (the app) or import (the API). Not fired by the CSV import, nor for a role that follows a profile field, nor for a role the person already held. Needs the Skills management feature.
{
"event": "job_role.assigned",
"data": {
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"jobRoleId": "2c1b0a9f-8e7d-4c6b-9a5f-3e2d1c0b9a8f",
"source": "import",
"assignedAt": "2026-10-06T21:00:00.000Z"
}
}skill.attained
Fired when the level a person holds in a skill rises. source says why: completion (a course completed), assessment (an assessment passed), validated (a self-assessment validated), observed (an observed sign-off) or manual (an upgrade-only raise recorded by an administrator outside those flows; rare). A level set or corrected by hand on the user page is not an attainment and fires nothing, nor does a level that drops. previousLevel is null when the person held no level. validUntil is null for a level that does not expire.
{
"event": "skill.attained",
"data": {
"userId": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"skillId": "7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918",
"level": "proficient",
"previousLevel": "developing",
"source": "completion",
"earnedAt": "2026-10-06T21:00:00.000Z",
"validUntil": null
}
}Complete Express.js Receiver Example
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.EMBAYLMS_WEBHOOK_SECRET; // whsec_…
// Use the raw body parser for signature verification
app.use('/webhooks/embaylms', express.raw({ type: 'application/json' }));
app.post('/webhooks/embaylms', async (req, res) => {
// 1. Verify signature (t=…,v1=… header over `${t}.${rawBody}`)
const header = req.headers['x-embaylms-signature'];
if (!header) return res.status(400).json({ error: 'Missing signature header' });
try {
verifyEmbayLmsSignature(header, req.body, WEBHOOK_SECRET);
} catch (err) {
return res.status(401).json({ error: 'Invalid signature' });
}
// 2. Return 200 immediately — process asynchronously
res.status(200).json({ received: true });
// 3. Parse and handle the event
const event = JSON.parse(req.body.toString());
// 4. Use the envelope id for idempotency
const alreadyProcessed = await checkIdempotency(event.id);
if (alreadyProcessed) return;
switch (event.event) {
case 'enrollment.completed':
await handleEnrollmentCompleted(event.data);
break;
case 'certificate.issued':
await handleCertificateIssued(event.data);
break;
default:
console.log(`Unhandled event type: ${event.event}`);
}
});
app.listen(3000);Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No deliveries arrive at all | Plan lacks API access (Growth+), or the endpoint is not subscribed to the event | Upgrade the plan; edit the endpoint’s event selection |
Deliveries show failed with no response code | Network error or your server took longer than 10 s to respond | Return 200 immediately and process asynchronously |
| Signature verification fails | Verifying against the parsed/re-serialized body instead of the raw bytes | Verify against the raw request body before any JSON parsing |
| Duplicate events processed | Retries after a timeout your server actually handled | Deduplicate on the envelope id / x-embaylms-delivery header |
| Deliveries stopped after deleting/re-adding an endpoint | The new endpoint has a new secret | Update EMBAYLMS_WEBHOOK_SECRET to the newly issued secret |