Build

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:

cURLPOST /v1/webhooks
Copy
$ 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"]}'
Response201 Created
application/json
{
  "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:

ResponsePOST
application/json
{
  "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:

HTTPHeaders on every webhook delivery
Copy
Lectern-Event: fee.reconciled
Lectern-Delivery: dlv_8aJ4F2pYxQRk
Lectern-Timestamp: 1762520528
Lectern-Signature: t=1762520528,v1=5d41402abc4b2a76b9719d911017c592

To 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.

NodeVerifying a signature
Copy
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_at as the source of truth.