Webhooki
Webhooki wysyłają zdarzenia na Twój adres HTTPS, więc nie trzeba odpytywać API.
Zdarzenia
| Zdarzenie | Kiedy |
|---|---|
course.completed |
Osoba ukończyła lub zaliczyła szkolenie. |
course.failed |
Próba zaliczenia szkolenia zakończyła się niepowodzeniem. |
compliance.certified |
Wydano nowy certyfikat. |
compliance.expired |
Certyfikat wygasł i nie ma następcy. |
enrollment.assigned |
Przydzielono szkolenie (API, reguły, panel administratora, odnowienie). |
enrollment.cancelled |
Anulowano przydział, którego jeszcze nie rozpoczęto. |
group.member_joined / group.member_left |
Zmieniła się przynależność do grupy. |
ping |
Zdarzenie testowe wysłane na żądanie. |
Schematy treści zdarzeń są w referencji API (sekcja Webhooks). Każde zdarzenie ma kopertę
{"id", "type", "created_at", "data"}.
Subskrypcja
Klient z zakresem webhooks:manage zarządza własnymi subskrypcjami. Host docelowy musi być na liście hostów
dozwolonych dla klienta, ustawionej przez administratora IT.
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"]}'Odpowiedź zawiera secret_token tylko raz — zapisz go; podpisuje każde doręczenie. Zdarzenie testowe wyślesz przez
POST /api/v1/webhooks/subscriptions/{id}/ping.
Zasady adresu docelowego: publiczna nazwa hosta https:// na porcie 443 (bez adresów IP i adresów prywatnych).
Doręczenia wychodzą ze stałego adresu platformy; jeśli Twoja zapora używa listy dozwolonych adresów, poproś o niego
opiekuna wdrożenia.
Weryfikacja podpisu
Każde doręczenie ma nagłówki:
X-LMS-Signature: t=1760000000,v1=5d41402abc4b2a76b9719d911017c592…
X-LMS-Event: course.completed
X-LMS-Event-Id: 7f3c2a9e-1b4d-4c8a-9e2f-5a6b7c8d9e01Oblicz hex(HMAC-SHA256(secret_token, "{t}.{surowa treść żądania}")) i porównaj wynik — w stałym czasie — z każdą
wartością v1. Odrzuć żądanie, gdy nic się nie zgadza albo gdy t różni się od Twojego zegara o więcej niż 5 minut.
Używaj surowych bajtów treści — parsowanie i ponowna serializacja JSON je zmienia.
# 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;
}
}Sprawdź swoją implementację na wektorach testowych (poprawny podpis, rotacja sekretu, zły sekret, zmieniona treść, przeterminowany znacznik czasu).
Doręczanie i ponowienia
- Potwierdź odbiór dowolną odpowiedzią 2xx w ciągu 5 sekund. Właściwą pracę wykonuj asynchronicznie.
- W przeciwnym razie doręczenie jest ponawiane po 60 s, 5 min, 30 min i 2 h (łącznie 5 prób).
- Doręczenie odbywa się co najmniej raz: usuwaj duplikaty po
idzdarzenia (X-LMS-Event-Id). - Kolejność nie jest gwarantowana; gdy ma znaczenie, korzystaj z
created_ati bieżącego stanu. - Po 5 kolejnych niepowodzeniach subskrypcja ma stan
degraded(doręczenia trwają dalej); po 20 kolejnych niepowodzeniach albo 3 dniach niepowodzeń —disabled, a administratorzy IT dostają e-mail. Włączysz ją ponownie przezPOST …/{id}/enable; zaległe doręczenia zostaną wtedy wysłane. GET …/{id}/deliveriespokazuje dziennik doręczeń (tylko metadane, przechowywane 30 dni);POST …/deliveries/{id}/retryponawia jedno nieudane doręczenie.
Rotacja sekretu
POST /api/v1/webhooks/subscriptions/{id}/rotate-secret od razu zwraca nowy sekret. Przez grace_days dni (domyślnie 7,
maksymalnie 30) doręczenia mają dwa podpisy v1 — nowym i poprzednim sekretem — więc możesz wdrożyć nowy sekret bez
utraty zdarzeń.