Your LMS just recorded a course completion. Three seconds later, your CRM has updated the contact record, a certificate PDF is in the learner’s inbox, and your HRIS compliance log has been stamped. No scheduled job ran. No admin clicked anything. That’s the operational promise of webhook-driven LMS integration – and this guide explains exactly how to build it correctly.
This guide targets xAPI 1.0.3 (IEEE 9274.1.1-2023), LTI Advantage, and REST-based webhook implementations across enterprise LMS platforms. It covers payload architecture, HMAC-SHA256 security, retry semantics, idempotency, and the platform-specific configuration steps that vendor documentation routinely skips.
The Core Technical Architecture: Event-Driven LMS Integration
Webhooks vs. API Polling: Why the Distinction Matters at Scale
Most LMS integrations start as polling loops – a scheduled job hits /api/v1/completions?since=last_run every 15 minutes. This works at 50 learners. At 5,000 learners with concurrent completions, you’re burning API quota, introducing 15-minute data lag, and fighting rate limits. Docebo, for instance, caps its REST API at 1,000 calls per hour per endpoint per IP.
Webhooks invert the model: the LMS posts an HTTP callback to your endpoint the moment an event fires. The architectural difference is the difference between polling your post office every hour versus having mail delivered to your door.
The formal webhook transport is a standard HTTP POST request with:
- Content-Type: application/json
- A platform-specific signature header (e.g., X-Absorb-Signature, X-Hub-Signature-256)
- A JSON body structured as an event envelope containing event type, metadata, and nested payload
Webhook Payload Structure (Canonical Pattern)
The following structure represents the cross-platform canonical pattern observed across enterprise LMS implementations, using course_completion as the event type:
{
“id”: “wh-20260408-143022-8a3c1d7e-bb21-4f19-a312-7ec20b90ca44”,
“event”: “course.completion”,
“version”: “1.0”,
“fired_at”: “2026-04-08T14:30:22.000Z”,
“timestamp”: 1744124222,
“original_domain”: “acme.yourplatform.com”,
“payload”: {
“user”: {
“id”: 10291,
“email”: “jsmith@acme.com”,
“first_name”: “Jane”,
“last_name”: “Smith”,
“external_id”: “HR-4829”
},
“course”: {
“id”: 5541,
“title”: “Annual Compliance Training 2026”,
“code”: “COMP-2026”,
“completion_date”: “2026-04-08T14:30:00Z”,
“score”: 91.5,
“passed”: true
},
“certificate”: {
“issued”: true,
“expiry_date”: “2027-04-08T00:00:00Z”
}
}
}
Platform-specific variations exist – Docebo wraps payloads under payload, Adobe Learning Manager uses a batch array for high-volume event delivery, and Absorb exposes EnrollmentId as the primary record key.
xAPI 1.0.3 Integration: Statement Dispatch on Learning Events
When your architecture requires xAPI 1.0.3 compliance (the current specification, published as IEEE 9274.1.1-2023), webhooks become the trigger that dispatches xAPI Statements to an LRS (Learning Record Store). A webhook fires on course.completion; your listener constructs and POSTs a conformant Statement to your LRS endpoint.
A minimal compliant xAPI Statement generated from a webhook payload:
{
“actor”: {
“objectType”: “Agent”,
“mbox”: “mailto:jsmith@acme.com”,
“name”: “Jane Smith”
},
“verb”: {
“id”: “http://adlnet.gov/expapi/verbs/completed”,
“display”: { “en-US”: “completed” }
},
“object”: {
“objectType”: “Activity”,
“id”: “https://lms.acme.com/courses/5541”,
“definition”: {
“name”: { “en-US”: “Annual Compliance Training 2026” },
“type”: “http://adlnet.gov/expapi/activities/course”
}
},
“result”: {
“completion”: true,
“success”: true,
“score”: { “scaled”: 0.915, “raw”: 91.5, “min”: 0, “max”: 100 }
},
“timestamp”: “2026-04-08T14:30:00Z”
}
The ADL Initiative’s xAPI specification requires actor, verb, and object as mandatory fields. result.score.scaled must be a decimal between -1 and 1, not the raw percentage – a common source of LRS rejection errors.
LMS Webhook Compatibility Matrix
The following matrix reflects native webhook support as of Q1 2026, based on official vendor documentation. “Native” means the platform fires HTTP POST callbacks without a middleware adapter. “Via Plugin/Connector” means a marketplace extension or third-party iPaaS adapter is required.
| LMS Platform | Native Webhooks | Supported Event Categories | Signature Method | Max Active Webhooks | Retry Behavior | Notes |
|---|---|---|---|---|---|---|
| Docebo | ✅ Yes | Enrollment, Completion, User, Group, Course | HMAC-SHA256 (optional) | 10 per platform | Best-effort, no built-in retry | Webhooks app must be activated separately; delays possible under load |
| Absorb LMS | ✅ Yes | Learner activity, Admin events, Competencies | HMAC-SHA256 (absorb-signature header) | Not published | Not published | Sandbox testing required before production |
| Adobe Learning Manager | ✅ Yes | Enrollment, Completion, Unenrollment, Job Aid | HMAC-SHA256 | Not published | Retry on non-2xx | Batch delivery model for high-volume events |
| TalentLMS | ⚠️ Partial | Course completion, User creation | None (IP allowlist only) | Not published | No built-in retry | Requires Zapier/Make for complex routing |
| Moodle | ⚠️ Via Plugin | All Moodle events (via local_webhooks plugin) | Optional HMAC | Unlimited | Configurable | Core has no native webhook support; plugin required |
| Canvas LMS | ⚠️ Via Live Events | SIS imports, submissions, course activity | JWT (institutional key) | N/A (stream-based) | Kafka-backed stream | Canvas Data 2 / Live Events uses an event stream, not traditional webhooks |
| D2L Brightspace | ✅ Yes | Grade updated, Enrollment, Content completion | HMAC-SHA256 | Not published | Yes (configurable) | Requires Data Hub entitlement for full event access |
| Cornerstone OnDemand | ✅ Yes | Transcript, Certification, User | API key header | Not published | Yes | Event schema varies by module; verify against your edition |
| SAP SuccessFactors LMS | ⚠️ Via BTP | Learning events via SAP BTP Integration Suite | OAuth 2.0 | N/A | BTP-managed | Direct webhook not supported; route through Integration Suite |
| LearnUpon | ✅ Yes | Course completion, Enrollment, User, Group | Shared secret (SHA256) | Not published | Yes (exponential backoff) | Secret header is X-LearnUpon-Webhook-Signature |
Note for Canvas users: Canvas Live Events is a Kafka-based streaming architecture, not a traditional webhook. Consume it via AWS SQS, Azure Event Hubs, or a Caliper IMS sink – not a standard HTTP endpoint.
Implementation: Configuring and Receiving LMS Webhooks
Step 1: Register the Webhook Endpoint in Your LMS
Your receiver must be publicly accessible over HTTPS. Self-signed certificates will cause delivery failures on all major platforms. Configure your endpoint in the LMS admin panel:
Docebo: Admin → Apps & Features → Webhooks → Add New Webhook. Select events, enter your HTTPS callback URL, optionally configure the secret for signature verification.
Absorb: Admin → Integrations → Webhooks → Add Webhook. Configure the Secret Key here – Absorb signs payloads using HMAC-SHA256 and places the result in the absorb-signature header.
Adobe Learning Manager: Integration Admin → Webhooks → Register. Choose events from the predefined catalog; ALM supports filtering by learner group or catalog scope.
Step 2: Implement HMAC-SHA256 Signature Verification
Every production webhook handler must verify the incoming signature before processing the payload. Using a plain string equality operator (==) is insufficient – use a constant-time comparison to prevent timing attacks.
Node.js implementation:
const crypto = require(‘crypto’);
function verifyWebhookSignature(rawBody, signatureHeader, secret) {
const computedSig = crypto
.createHmac(‘sha256’, secret)
.update(rawBody) // rawBody must be the raw Buffer, not parsed JSON
.digest(‘hex’);
// Constant-time comparison prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(computedSig, ‘hex’),
Buffer.from(signatureHeader.replace(‘sha256=’, ”), ‘hex’)
);
}
app.post(‘/webhooks/lms’, express.raw({ type: ‘application/json’ }), (req, res) => {
const sig = req.headers[‘absorb-signature’] || req.headers[‘x-hub-signature-256’];
if (!verifyWebhookSignature(req.body, sig, process.env.WEBHOOK_SECRET)) {
return res.status(401).send(‘Signature verification failed’);
}
// Acknowledge receipt immediately – do processing async
res.status(200).send(‘OK’);
processEvent(JSON.parse(req.body)); // offload to queue
});
Python (FastAPI) implementation:
import hmac, hashlib
from fastapi import Request, HTTPException
async def verify_signature(request: Request, secret: str) -> bytes:
body = await request.body()
sig_header = request.headers.get(“absorb-signature”, “”)
computed = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(computed, sig_header):
raise HTTPException(status_code=401, detail=”Invalid signature”)
return body
Critical: Parse the body from raw bytes, never from the already-deserialized object. Any middleware that re-serializes JSON (adding/removing spaces) will produce a signature mismatch.
Step 3: Respond Immediately, Process Asynchronously
LMS platforms timeout webhook delivery if your endpoint doesn’t respond within their window (typically 5–30 seconds). Absorb, Adobe ALM, and LearnUpon all retry on non-2xx responses. If you do downstream API calls, database writes, or PDF generation synchronously inside the handler, you will trigger unnecessary retries.
The correct pattern: acknowledge with 200 OK immediately, push the event body to an internal queue (Redis, SQS, RabbitMQ), process in a separate worker.
// Handler: fast acknowledgment only
app.post(‘/webhooks/lms’, async (req, res) => {
await queue.push({ id: req.body.id, raw: req.body });
res.status(200).send(‘OK’); // respond in <500ms
});
// Worker: all business logic runs here
async function processEvent(event) {
if (await db.exists(‘processed_events’, event.id)) return; // idempotency check
await db.insert(‘processed_events’, event.id);
// CRM update, certificate issue, HRIS sync, xAPI dispatch…
}
Step 4: Implement Idempotency
Webhook providers guarantee at-least-once delivery, meaning the same event may arrive multiple times – especially after your endpoint returns a 5xx error. Store each incoming event.id (or equivalent unique identifier) and skip processing if it has already been handled.
Without idempotency, a transient database error during a course.completion event will cause the learner to receive duplicate certificates when the LMS retries delivery.
Common Issues and Fixes
Issue 1: Signature Verification Always Fails
Root cause: Body has been parsed and re-serialized by middleware before reaching the verification step, altering whitespace or key ordering.
Fix: Register the webhook route with raw body parsing before any JSON middleware runs. In Express: express.raw({ type: ‘application/json’ }) on the specific route. Store the raw Buffer; compute the HMAC against bytes, not string.
Issue 2: Duplicate Events Being Processed
Root cause: The LMS retried delivery (due to a slow response or intermittent 5xx) and your handler processed both deliveries.
Fix: Add an event_id uniqueness constraint to your processing log table. Check before processing; skip if already present. This is idempotency – the standard guarantee for at-least-once delivery systems.
Issue 3: xAPI Statement Rejected by LRS (400 Bad Request)
Root cause: result.score.scaled is outside the -1.0 to 1.0 range, or actor.mbox is not a valid mailto: URI. Both are common when mapping LMS payload fields directly without normalization.
Fix: Normalize the score: scaled = rawScore / maxScore. Validate the actor identifier against the xAPI 1.0.3 spec before dispatch. Use an LRS sandbox (SCORM Cloud’s LRS, Learning Locker) to validate statement structure during development.
Issue 4: Webhook Endpoint Times Out Under Load (Completion Spikes)
Root cause: Synchronous processing inside the HTTP handler during high-concurrency events (e.g., a mandatory course deadline causing hundreds of simultaneous completions).
Fix: Push events to a queue immediately and return 200 OK. Use exponential backoff with jitter in your consumer to avoid thundering-herd retry storms. Implement a Dead Letter Queue (DLQ) for events that exhaust retries – retain for 7–14 days minimum for forensic replay.
Issue 5: Docebo Webhook Events Delayed or Missing
Root cause: Docebo’s webhook engine introduces deliberate delay under platform load. This is by design – Docebo’s own documentation notes that events should not be relied upon for instantaneous actions.
Fix: For time-sensitive workflows (e.g., unlocking sequential courses immediately after completion), supplement webhooks with a polling fallback on the Docebo REST API for near-real-time reconciliation.
Issue 6: Moodle Webhooks Not Firing After Plugin Installation
Root cause: The local_webhooks plugin requires explicit event subscription configuration via Admin → Site Administration → Plugins → Local plugins → Webhooks. Installing the plugin alone is insufficient; each event type must be individually enabled and mapped to an endpoint.
Fix: After installation, navigate to the plugin configuration, add your HTTPS endpoint, and enable each Moodle core event you need (e.g., \core\event\course_completed, \core\event\user_enrolment_created). Test using Moodle’s built-in webhook test dispatch before going live.
Tip from the field:
On platforms that don’t include a native idempotency key in the webhook payload (TalentLMS, some Moodle plugin configurations), construct your own by hashing a combination of user_id + course_id + completion_date into a SHA-256 fingerprint. Store this as your deduplication key. This handles the edge case where a learner legitimately completes the same course twice – the second completion will have a different completion_date, generating a different fingerprint and correctly processing as a new event. Without this nuance, naive user_id + course_id deduplication will silently drop legitimate re-completions in annual compliance workflows.
FAQ
Q1. Should I use webhooks or xAPI for LMS integration, they seem to overlap?
They operate at different layers and are typically complementary, not competing. Webhooks are the transport mechanism: the LMS pushes an HTTP notification to your system when something happens. xAPI (1.0.3 / IEEE 9274.1.1-2023) is a data specification: a structured vocabulary for describing learning experiences that your system then records in an LRS. In a mature architecture, a webhook fires on course completion, your listener validates and acknowledges receipt, then constructs and dispatches an xAPI Statement to the LRS. Platforms like SCORM Cloud can act as both the content host and the LRS endpoint simultaneously.
Q2. How do I handle webhook delivery orders, can completion events arrive before enrollment events?
Yes. HTTP-based webhook delivery has no guaranteed ordering guarantees, especially under load or during retry sequences. Design your event handlers to be stateless and self-contained: each payload must carry enough context to be processed independently, regardless of what arrived before it. Do not build state machines that depend on event sequence. If your downstream system requires ordered processing (e.g., a workflow that must confirm enrollment before logging completion), query the LMS REST API to validate current state when processing a completion event, rather than relying on a prior enrollment webhook having been received and processed.
Q3. What's the right approach for multi-tenant LMS architectures where multiple customer subdomains fire webhooks to the same endpoint?
Route on the original_domain field present in most enterprise LMS payloads (Docebo, Adobe ALM, Absorb), or use distinct endpoint paths per tenant (/webhooks/lms/{tenant_id}). Issue a separate HMAC secret per tenant and store it indexed by tenant identifier. Never use a shared secret across tenants – a compromised tenant secret must be rotatable without affecting others. Log tenant_id alongside every processed event for audit trail completeness, which most compliance frameworks (SOC 2, ISO 27001) will require during review.