Webhooks & signatures
Configure endpoints under Admin → Webhooks: a URL, a set of events, and a whsec_… signing secret shown at creation. Payloads deliberately carry ids and changed field names (plus coarse values like a status word) — fetch details through the read API, where token scopes apply.
Events
| Event | Fires when |
|---|---|
item.updated | An item's fields changed. |
item.moved | An item changed location. |
service_entry.created | A service entry was logged. |
loan.checked_out | Gear went out. |
loan.checked_in | Gear came back. |
qr.assigned | A code was bound to an item. |
Delivery
Every POST carries three headers: X-Leidang-Event, X-Leidang-Timestamp (unix seconds), and X-Leidang-Signature — sha256=<hex HMAC-SHA256(secret, timestamp.body)>. A non-2xx response is retried up to twice with backoff; each delivery's final outcome is inspectable under Admin → Webhooks.
Verifying the signature
Recompute the HMAC over the exact timestamp.rawBody string before parsing the body, compare constant-time, and reject stale timestamps to close replay windows:
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyLeidangWebhook(secret, req, rawBody) {
const timestamp = req.headers["x-leidang-timestamp"];
const header = req.headers["x-leidang-signature"]; // "sha256=<hex>"
if (!timestamp || !header) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
"sha256=" + createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}Verify against the raw request bytes, not a re-serialized JSON object — any reformatting breaks the HMAC.