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

  1. Sign in to your EmbayLMS tenant as an admin.
  2. Navigate to Settings -> Integrations -> Webhooks (the Webhooks tab of the unified Integrations menu).
  3. Click New Endpoint.
  4. Enter your endpoint URL (must be HTTPS).
  5. Select the events you want to receive.
  6. 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:

HeaderValue
content-typeapplication/json
x-embaylms-eventThe event type (e.g. enrollment.created)
x-embaylms-deliveryThe delivery UUID — use it for idempotency
x-embaylms-signaturet=<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=3d9b2e1f4a5c6789abcdef0123456789abcdef0123456789abcdef0123456789
  • t — 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:

AttemptDelay after previous failure
1immediate
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:

FieldMeaning
StatusDelivered, Failed, or Pending (enqueued, still inside the retry window)
EventThe event type, e.g. enrollment.created
HTTP statusWhat your endpoint replied, or “No response yet”
AttemptsHow many of the 5 attempts have been made
Response bodyYour 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 id included. If you deduplicate on the envelope id or the x-embaylms-delivery header (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 the x-embaylms-delivery header) 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": { }
}
FieldTypeDescription
idUUIDUnique delivery ID. Use for idempotency.
eventstringThe event type (see catalog below)
createdAtISO 8601When the event was recorded
dataobjectEvent-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

SymptomLikely causeFix
No deliveries arrive at allPlan lacks API access (Growth+), or the endpoint is not subscribed to the eventUpgrade the plan; edit the endpoint’s event selection
Deliveries show failed with no response codeNetwork error or your server took longer than 10 s to respondReturn 200 immediately and process asynchronously
Signature verification failsVerifying against the parsed/re-serialized body instead of the raw bytesVerify against the raw request body before any JSON parsing
Duplicate events processedRetries after a timeout your server actually handledDeduplicate on the envelope id / x-embaylms-delivery header
Deliveries stopped after deleting/re-adding an endpointThe new endpoint has a new secretUpdate EMBAYLMS_WEBHOOK_SECRET to the newly issued secret