Webhooks
Webhooks push events to your HTTPS endpoint, so you don't have to poll.
Events
| Event | When |
|---|---|
course.completed |
A person completed or passed a course. |
course.failed |
A course attempt was failed. |
compliance.certified |
A new certificate was issued. |
compliance.expired |
A certificate expired without a successor. |
enrollment.assigned |
A course was assigned (API, rules, admin panel, renewal). |
enrollment.cancelled |
A not-yet-started assignment was cancelled. |
group.member_joined / group.member_left |
Group membership changed. |
ping |
Test event sent on request. |
Payload schemas are in the API reference (section Webhooks). Every event has the envelope
{"id", "type", "created_at", "data"}.
Subscribe
A client with the webhooks:manage scope manages its own subscriptions. The target host must be on the client's
allowed host list set by your IT administrator.
curl https://api.elearning.edu.pl/api/v1/webhooks/subscriptions -H "X-Tenant-Code: your-tenant" -H "X-API-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"name": "HR sync", "target_url": "https://hr.example.com/hooks/training",
"subscribed_events": ["course.completed", "compliance.expired"]}'The response contains secret_token once — store it; it signs every delivery. Use
POST /api/v1/webhooks/subscriptions/{id}/ping to send a test event.
Target URL rules: public https:// host name on port 443 (no IP addresses, no private addresses). Deliveries come from a
fixed platform address; ask your onboarding contact for it if your firewall uses an allowlist.
Verify the signature
Each delivery carries:
X-LMS-Signature: t=1760000000,v1=5d41402abc4b2a76b9719d911017c592…
X-LMS-Event: course.completed
X-LMS-Event-Id: 7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e01Compute hex(HMAC-SHA256(secret_token, "{t}.{raw request body}")) and compare it, in constant time, with each v1
value. Reject the request when nothing matches or when t is more than 5 minutes away from your clock. Use the raw
body bytes — parsing and re-serialising JSON changes them.
# Shell (OpenSSL) — for debugging a captured delivery. In production verify in your application code: this
# comparison is not constant-time.
# Usage: verify_signature "$X_LMS_SIGNATURE" body.json "$WEBHOOK_SECRET" [now]
verify_signature() {
local header="$1" body_file="$2" secret="$3" now="${4:-$(date +%s)}"
local timestamp="" expected part
for part in ${header//,/ }; do
case "$part" in t=*) timestamp="${part#t=}" ;; esac
done
case "$timestamp" in ''|*[!0-9]*) return 1 ;; esac
local age=$((now - timestamp))
[ "${age#-}" -le 300 ] || return 1
expected=$( { printf '%s.' "$timestamp"; cat "$body_file"; } | openssl dgst -sha256 -hmac "$secret" -r | cut -d' ' -f1)
for part in ${header//,/ }; do
[ "$part" = "v1=$expected" ] && return 0
done
return 1
}import hashlib
import hmac
import time
def verify_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300, now: float | None = None) -> bool:
"""Checks X-LMS-Signature: t={timestamp},v1={hex}[,v1={hex}] against the raw request body."""
timestamp, signatures = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures:
return False
if abs((time.time() if now is None else now) - timestamp) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
# Flask: verify_signature(request.headers["X-LMS-Signature"], request.get_data(), WEBHOOK_SECRET)<?php
/** Checks X-LMS-Signature: t={timestamp},v1={hex}[,v1={hex}] against the raw request body. */
function verify_signature(string $header, string $rawBody, string $secret, int $tolerance = 300, ?int $now = null): bool
{
$timestamp = null;
$signatures = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) {
$timestamp = (int) $value;
} elseif ($key === 'v1') {
$signatures[] = $value;
}
}
if ($timestamp === null || $signatures === []) {
return false;
}
if (abs(($now ?? time()) - $timestamp) > $tolerance) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($signatures as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
// verify_signature($_SERVER['HTTP_X_LMS_SIGNATURE'] ?? '', file_get_contents('php://input'), $webhookSecret);package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// VerifySignature checks X-LMS-Signature: t={timestamp},v1={hex}[,v1={hex}] against the raw request body.
func VerifySignature(header string, rawBody []byte, secret string, tolerance time.Duration, now time.Time) bool {
var timestamp int64 = -1
var signatures [][]byte
for _, part := range strings.Split(header, ",") {
key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
switch key {
case "t":
if t, err := strconv.ParseInt(value, 10, 64); err == nil && t >= 0 {
timestamp = t
}
case "v1":
if signature, err := hex.DecodeString(value); err == nil {
signatures = append(signatures, signature)
}
}
}
if timestamp < 0 || len(signatures) == 0 {
return false
}
if age := now.Sub(time.Unix(timestamp, 0)); age > tolerance || age < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(timestamp, 10) + "."))
mac.Write(rawBody)
expected := mac.Sum(nil)
for _, signature := range signatures {
if hmac.Equal(expected, signature) {
return true
}
}
return false
}
// body, _ := io.ReadAll(r.Body)
// ok := VerifySignature(r.Header.Get("X-LMS-Signature"), body, secret, 5*time.Minute, time.Now())import { createHmac, timingSafeEqual } from "node:crypto";
// Checks X-LMS-Signature: t={timestamp},v1={hex}[,v1={hex}] against the raw request body (a Buffer).
export function verifySignature(header, rawBody, secret, { tolerance = 300, now = Date.now() / 1000 } = {}) {
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const [key, value = ""] = part.trim().split("=", 2);
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === null || signatures.length === 0) return false;
if (Math.abs(now - timestamp) > tolerance) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// Express: app.post("/hooks/training", express.raw({ type: "application/json" }), (req, res) => {
// if (!verifySignature(req.get("X-LMS-Signature") ?? "", req.body, process.env.WEBHOOK_SECRET)) return res.sendStatus(401);
// res.sendStatus(204); // then process the event asynchronously
// });using System.Security.Cryptography;
using System.Text;
public static class WebhookSignature
{
/// <summary>Checks X-LMS-Signature: t={timestamp},v1={hex}[,v1={hex}] against the raw request body.</summary>
public static bool Verify(string header, byte[] rawBody, string secret, TimeSpan? tolerance = null, DateTimeOffset? now = null)
{
long? timestamp = null;
var signatures = new List<string>();
foreach (var part in header.Split(','))
{
var pair = part.Trim().Split('=', 2);
if (pair.Length != 2) continue;
if (pair[0] == "t" && long.TryParse(pair[1], out var t) && t >= 0) timestamp = t;
else if (pair[0] == "v1") signatures.Add(pair[1]);
}
if (timestamp is null || signatures.Count == 0) return false;
var age = (now ?? DateTimeOffset.UtcNow) - DateTimeOffset.FromUnixTimeSeconds(timestamp.Value);
if (age.Duration() > (tolerance ?? TimeSpan.FromMinutes(5))) return false;
var payload = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(rawBody).ToArray();
var expected = Encoding.ASCII.GetBytes(Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), payload)).ToLowerInvariant());
return signatures.Any(s => CryptographicOperations.FixedTimeEquals(expected, Encoding.ASCII.GetBytes(s)));
}
}
// ASP.NET Core: read the raw body before model binding, e.g.
// using var buffer = new MemoryStream(); await Request.Body.CopyToAsync(buffer);
// WebhookSignature.Verify(Request.Headers["X-LMS-Signature"].ToString(), buffer.ToArray(), secret);import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class WebhookSignature {
private WebhookSignature() {
}
/** Checks X-LMS-Signature: t={timestamp},v1={hex}[,v1={hex}] against the raw request body. */
public static boolean verify(String header, byte[] rawBody, String secret) throws Exception {
return verify(header, rawBody, secret, 300, System.currentTimeMillis() / 1000);
}
public static boolean verify(String header, byte[] rawBody, String secret, long toleranceSeconds, long now) throws Exception {
long timestamp = -1;
List<String> signatures = new ArrayList<>();
for (String part : header.split(",")) {
String[] pair = part.trim().split("=", 2);
if (pair.length != 2) {
continue;
}
if (pair[0].equals("t") && pair[1].matches("\\d+")) {
timestamp = Long.parseLong(pair[1]);
} else if (pair[0].equals("v1")) {
signatures.add(pair[1]);
}
}
if (timestamp < 0 || signatures.isEmpty() || Math.abs(now - timestamp) > toleranceSeconds) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
byte[] expected = HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(StandardCharsets.US_ASCII);
for (String signature : signatures) {
if (MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.US_ASCII))) {
return true;
}
}
return false;
}
}Test your implementation against the test vectors (valid, rotated, wrong secret, modified body, expired timestamp).
Delivery and retries
- Respond with any 2xx within 5 seconds to acknowledge. Do the work asynchronously.
- Otherwise the delivery is retried after 60 s, 5 min, 30 min and 2 h (5 attempts in total).
- Delivery is at least once: deduplicate by the event
id(X-LMS-Event-Id). - Order is not guaranteed; use
created_atand the current state when it matters. - After 5 consecutive failures the subscription is
degraded(deliveries continue); after 20 consecutive failures or 3 days of failures it isdisabledand your IT administrators get an e-mail. Re-enable it withPOST …/{id}/enable; pending deliveries are then sent. GET …/{id}/deliveriesshows the delivery log (metadata only, kept for 30 days);POST …/deliveries/{id}/retryretries one failed delivery.
Rotate the secret
POST /api/v1/webhooks/subscriptions/{id}/rotate-secret returns a new secret immediately. For grace_days
(default 7, max 30) deliveries carry two v1 signatures — new and previous secret — so you can deploy the new secret
without losing events.