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-Signature header, 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

  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 Add Endpoint.
  4. Enter your endpoint URL (must be HTTPS).
  5. Select the events you want to receive.
  6. 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: 1748995260

The 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:

AttemptDelay
1st retry5 minutes
2nd retry30 minutes
3rd retry2 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_id for idempotency. EmbayLMS may deliver the same event more than once (e.g., after a retry). Store processed event_id values 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": { }
}
FieldTypeDescription
event_idUUIDUnique ID for this delivery. Use for idempotency.
event_typestringThe event type (see catalog below)
tenant_idstringYour tenant slug
created_atISO 8601When the event occurred
dataobjectEvent-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);