Webhooks
⚠️ Outbound delivery is Planned — not yet available. You can create and manage webhook endpoints today, but EmbayLMS does not yet dispatch events to them: there is no delivery worker, and the
X-EmbayLMS-Signatureheader, retry policy, and event payloads described below are the intended contract, not live behavior. Do not build a production integration that depends on receiving webhook deliveries until this ships. Continue to poll the relevant endpoints in the meantime.
EmbayLMS sends webhook events to your server when things happen in your tenant — a user completes a course, a certificate is issued, a subscription changes. Webhooks let your integrations react in real time without polling.
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 Add Endpoint.
- Enter your endpoint URL (must be HTTPS).
- Select the events you want to receive.
- Click Save. EmbayLMS generates a Webhook Secret — copy it immediately.
You can add up to 10 webhook endpoints per tenant.
Signature Verification
Every webhook request includes an X-EmbayLMS-Signature header. This is an HMAC-SHA256 signature computed over the raw request body using your webhook secret. Always verify this signature before processing the payload.
Signature Format
X-EmbayLMS-Signature: sha256=3d9b2e1f4a5c6789abcdef0123456789abcdef0123456789abcdef0123456789
X-EmbayLMS-Timestamp: 1748995260The X-EmbayLMS-Timestamp is a Unix timestamp (seconds). Reject events where the timestamp is more than 5 minutes old — this prevents replay attacks.
Verification Algorithm
signature_payload = timestamp + "." + raw_request_body
expected_signature = HMAC-SHA256(webhook_secret, signature_payload)Node.js Verification Example
const crypto = require('crypto');
function verifyWebhookSignature(req, webhookSecret) {
const signature = req.headers['x-embaylms-signature'];
const timestamp = req.headers['x-embaylms-timestamp'];
if (!signature || !timestamp) {
throw new Error('Missing signature headers');
}
// Reject stale events (older than 5 minutes)
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) {
throw new Error('Webhook timestamp too old');
}
const payload = `${timestamp}.${req.rawBody}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', webhookSecret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
throw new Error('Invalid signature');
}
}Use
crypto.timingSafeEqual— never compare signatures with===(timing attack vulnerability).
Retry Policy
If your endpoint returns a non-2xx status or times out, EmbayLMS retries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1st retry | 5 minutes |
| 2nd retry | 30 minutes |
| 3rd retry | 2 hours |
After 3 failed retries (4 total attempts over ~2.5 hours), the delivery is marked failed and no further retries are made. Failed deliveries are visible in Settings -> Integrations -> Webhooks -> Delivery Logs.
Best Practices
- Return 200 immediately. Process the event asynchronously — do not block the response on database writes or downstream API calls.
- Use the
event_idfor idempotency. EmbayLMS may deliver the same event more than once (e.g., after a retry). Store processedevent_idvalues and skip duplicates. - Use HTTPS only. HTTP endpoints are rejected.
- Verify the signature on every request. Do not skip this step in production.
Webhook Payload Envelope
Every webhook payload shares this top-level structure:
{
"event_id": "9b0c1d2e-3f4a-5678-bcde-678901234567",
"event_type": "enrollment.completed",
"tenant_id": "acme",
"created_at": "2026-06-05T11:00:00Z",
"data": { }
}| Field | Type | Description |
|---|---|---|
event_id | UUID | Unique ID for this delivery. Use for idempotency. |
event_type | string | The event type (see catalog below) |
tenant_id | string | Your tenant slug |
created_at | ISO 8601 | When the event occurred |
data | object | Event-specific payload |
Event Catalog
user.created
Fired when a new user is created in the tenant.
{
"event_type": "user.created",
"data": {
"id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"email": "jane.doe@acme.com",
"first_name": "Jane",
"last_name": "Doe",
"role": "learner",
"created_at": "2026-06-05T10:00:00Z"
}
}user.updated
Fired when a user’s profile is updated.
{
"event_type": "user.updated",
"data": {
"id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"changed_fields": ["role", "group_ids"],
"previous": { "role": "learner" },
"current": { "role": "instructor" },
"updated_at": "2026-06-05T10:05:00Z"
}
}user.deactivated
Fired when a user is deactivated.
{
"event_type": "user.deactivated",
"data": {
"id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"deactivated_at": "2026-06-05T10:10:00Z"
}
}enrollment.created
Fired when a user is enrolled in a course.
{
"event_type": "enrollment.created",
"data": {
"id": "d6e5f4a3-7b8c-9012-defa-123456789012",
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"course_title": "WHMIS 2015",
"due_date": "2026-09-01T00:00:00Z",
"enrolled_at": "2026-06-05T10:30:00Z"
}
}enrollment.completed
Fired when a learner completes a course.
{
"event_type": "enrollment.completed",
"data": {
"id": "d6e5f4a3-7b8c-9012-defa-123456789012",
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"course_title": "WHMIS 2015",
"completed_at": "2026-06-05T11:00:00Z",
"score": 92,
"pass_fail": "pass",
"certificate_id": "f8a7b6c5-9d0e-1234-fabc-345678901234"
}
}enrollment.overdue
Fired when an enrollment passes its due date without completion. Fires once at the time the due date passes.
{
"event_type": "enrollment.overdue",
"data": {
"id": "d6e5f4a3-7b8c-9012-defa-123456789012",
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"course_title": "WHMIS 2015",
"due_date": "2026-06-01T00:00:00Z",
"progress_percent": 40
}
}course.published
Fired when a course is published and becomes available to learners.
{
"event_type": "course.published",
"data": {
"id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"title": "WHMIS 2015",
"published_at": "2026-06-05T12:00:00Z"
}
}course.archived
Fired when a course is archived.
{
"event_type": "course.archived",
"data": {
"id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"title": "WHMIS 2015",
"archived_at": "2026-06-05T12:00:00Z"
}
}certificate.issued
Fired when a certificate is issued to a learner.
{
"event_type": "certificate.issued",
"data": {
"id": "f8a7b6c5-9d0e-1234-fabc-345678901234",
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"course_title": "WHMIS 2015",
"issued_at": "2026-06-05T11:00:00Z",
"expires_at": "2027-06-05T00:00:00Z",
"download_url": "https://{your-subdomain}.embaylms.com/certificates/f8a7b6c5-9d0e-1234-fabc-345678901234"
}
}certificate.revoked
Fired when a certificate is revoked by an admin.
{
"event_type": "certificate.revoked",
"data": {
"id": "f8a7b6c5-9d0e-1234-fabc-345678901234",
"user_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"revoked_at": "2026-06-05T14:00:00Z",
"reason": "Course content updated — re-enrollment required"
}
}ilt_session.created
Fired when an instructor-led training session is created.
{
"event_type": "ilt_session.created",
"data": {
"id": "1b2c3d4e-5f6a-7890-abcd-123456789012",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"title": "WHMIS 2015 — Live Session",
"scheduled_at": "2026-07-15T09:00:00Z",
"duration_minutes": 120,
"location": "Room 204, HQ",
"instructor_id": "a3f2c1d0-4e5b-6789-abcd-ef0123456789",
"capacity": 20
}
}ilt_session.cancelled
Fired when an instructor-led training session is cancelled.
{
"event_type": "ilt_session.cancelled",
"data": {
"id": "1b2c3d4e-5f6a-7890-abcd-123456789012",
"course_id": "e7f6a5b4-8c9d-0123-efab-234567890123",
"title": "WHMIS 2015 — Live Session",
"cancelled_at": "2026-07-10T08:00:00Z",
"reason": "Instructor unavailable"
}
}subscription.upgraded
Fired when the tenant upgrades to a higher subscription tier.
{
"event_type": "subscription.upgraded",
"data": {
"previous_plan": "starter",
"new_plan": "professional",
"effective_at": "2026-06-05T00:00:00Z"
}
}subscription.downgraded
Fired when the tenant downgrades to a lower subscription tier.
{
"event_type": "subscription.downgraded",
"data": {
"previous_plan": "professional",
"new_plan": "starter",
"effective_at": "2026-07-01T00:00:00Z"
}
}subscription.cancelled
Fired when the tenant cancels their subscription.
{
"event_type": "subscription.cancelled",
"data": {
"plan": "professional",
"cancelled_at": "2026-06-05T00:00:00Z",
"access_ends_at": "2026-06-30T23:59:59Z"
}
}Complete Express.js Receiver Example
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = process.env.EMBAYLMS_WEBHOOK_SECRET;
// Use 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
const signature = req.headers['x-embaylms-signature'];
const timestamp = req.headers['x-embaylms-timestamp'];
if (!signature || !timestamp) {
return res.status(400).json({ error: 'Missing signature headers' });
}
const age = Math.abs(Date.now() / 1000 - parseInt(timestamp, 10));
if (age > 300) {
return res.status(400).json({ error: 'Request timestamp too old' });
}
const payload = `${timestamp}.${req.body.toString()}`;
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
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 event_id for idempotency
const alreadyProcessed = await checkIdempotency(event.event_id);
if (alreadyProcessed) return;
switch (event.event_type) {
case 'enrollment.completed':
await handleEnrollmentCompleted(event.data);
break;
case 'certificate.issued':
await handleCertificateIssued(event.data);
break;
default:
console.log(`Unhandled event type: ${event.event_type}`);
}
});
app.listen(3000);