Webhooks & events
Lectern delivers signed, idempotent JSON to your endpoint when something changes. Configure once, listen forever.
Concept
Webhooks are HTTP POSTs to a URL you control, sent within a second of the source event. Every payload is signed; every delivery has a unique id so you can dedupe; every failed delivery is retried with exponential backoff for up to 24 hours.
Registering an endpoint
Add an endpoint in Settings → API → Webhooks, or via the API:
$ curl https://api.lectern.school/v1/webhooks \
-H "Authorization: Bearer sk_live_lectern_…" \
-H "Lectern-Version: 2026-01-01" \
-d '{"url": "https://hooks.example.com/lectern", "events": ["learner.enrolled", "fee.reconciled"]}'{
"id": "wh_2pYxQRk0",
"url": "https://hooks.example.com/lectern",
"events": [
"learner.enrolled",
"fee.reconciled"
],
"signing_secret": "whsec_8aJ4F2pYxQRk0ZqMnVbT9HwLcXKp",
"status": "active"
}Payload format
Every webhook delivers an envelope with the event metadata plus a data object containing the resource at the moment the event fired:
{
"id": "evt_5f3c0e2a",
"type": "fee.reconciled",
"version": "2026-01-01",
"created_at": "2026-05-07T11:42:08Z",
"school_id": "sch_lectern_demo",
"data": {
"invoice_id": "inv_04821",
"learner_id": "lrn_8aJ4F",
"amount": 124500,
"currency": "ZAR",
"reconciled_at": "2026-05-07T11:42:00Z"
}
}Signing & verification
Lectern signs the raw request body with HMAC-SHA256 using your webhook’s signing secret. The signature is delivered in the Lectern-Signature header along with a timestamp:
Lectern-Event: fee.reconciled
Lectern-Delivery: dlv_8aJ4F2pYxQRk
Lectern-Timestamp: 1762520528
Lectern-Signature: t=1762520528,v1=5d41402abc4b2a76b9719d911017c592To verify, compute HMAC-SHA256(signing_secret, timestamp + "." + raw_body) and constant-time compare against the v1 portion of the signature. Reject deliveries where the timestamp is more than five minutes old to prevent replay.
import crypto from "node:crypto";
function verify(req, secret) {
const sig = req.header("lectern-signature");
const ts = req.header("lectern-timestamp");
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(sig.split("v1=")[1]),
Buffer.from(expected),
);
}Retries & deduping
If your endpoint doesn’t respond with a 2xx within 10 seconds, Lectern retries with exponential backoff: 30 seconds, 5 minutes, 30 minutes, 2 hours, 6 hours, then once a day for up to 24 hours. After that, the delivery is marked as failed and visible in the webhook activity log.
Each event has a stable id (evt_…); each delivery has its own delivery_id (dlv_…). Dedupe on the event ID, not the delivery ID - retries share the event but get fresh delivery IDs.
Available events
The complete event catalog. New events are additive across versions:
learner.createdA new learner record was created.learner.enrolledA learner was enrolled in a class for a term.learner.archivedA learner was archived (soft-deleted).staff.invitedAn invitation was sent to a new staff member.staff.activatedAn invited staff member accepted and activated their account.attendance.recordedAttendance was taken for a class on a given date.assessment.publishedAn assessment was published to learners.term-report.publishedA term report was published to families.fee.invoicedA fee invoice was issued.fee.reconciledA payment was matched to an invoice.discipline.incident-loggedA discipline incident was logged for a learner.Best practices
- Respond fast, work async. Acknowledge with 200 OK quickly, then queue the actual processing. Webhook handlers should almost always be thin.
- Verify every signature.Reject anything that doesn’t match. Log rejections so you can spot a misconfigured rotation.
- Dedupe on event ID.A retry isn’t a duplicate event - it’s the same event delivered again.
- Handle out-of-order delivery.Lectern doesn’t guarantee strict ordering. Use
created_atas the source of truth.