Webhooks
Webhooks tell your system about things that happened in Institflow — an invoice was issued, a student went absent, a payment came in — without you polling for them. Every domain mutation is persisted to an outbox table in the same transaction as the change, so a delivery can never be sent for something that didn't actually happen.
Managing endpoints
/api/v1/webhooks (requires integration.manage) — create an endpoint with a url and the list of events it wants; the secret used to sign deliveries is shown exactly once, at creation. A test ping (POST /webhooks/:id/test) sends a real signed request to your url immediately, without waiting for a real event, so you can verify your handler before going live. See the full API Keys & Webhooks reference for every field.
Event catalogue
Every event type your endpoint can subscribe to, generated from packages/shared/src/events.ts. Every delivery's JSON body is { id, type, occurredAt, tenantId, data } — the fields below are what appears inside data.
| Event | Description | Payload fields |
|---|---|---|
| student.created | A new student record was created. | studentId, branchId, studentNumber, firstName, lastName, status |
| student.updated | A student's profile fields changed. | studentId, changes |
| student.status_changed | A student's lifecycle status changed (e.g. lead → active, active → withdrawn). | studentId, previousStatus, status |
| enrollment.created | A student was enrolled into a teaching group. | enrollmentId, studentId, groupId, priceMinor, currency, status |
| enrollment.dropped | A student's enrollment in a group was dropped. | enrollmentId, studentId, groupId, reason |
| session.cancelled | A scheduled class session was cancelled. | sessionId, groupId, scheduledStart, reason |
| attendance.marked | Attendance was recorded for every student on a session's roster. | sessionId, groupId, markedByUserId, absentStudentIds, counts |
| student.absent | A single student was marked absent (or late/excused) for a session. | studentId, sessionId, groupId, status, occurredOn |
| exam.published | An exam was published, making its results visible to students/guardians. | examId, groupId, title, publishedAt |
| progress_report.sent | A progress report was sent to a guardian. | progressReportId, studentId, sentAt |
| invoice.issued | An invoice moved from draft to issued. | invoiceId, studentId, number, totalMinor, currency, dueDate, status |
| invoice.overdue | An issued invoice passed its due date without being paid in full. | invoiceId, studentId, number, totalMinor, paidMinor, currency, dueDate |
| invoice.reminder_requested | A staff member asked for a payment reminder to be sent for an unpaid invoice. | invoiceId, studentId, number, totalMinor, paidMinor, currency, dueDate |
| payment.recorded | A payment was recorded against an invoice. | paymentId, invoiceId, amountMinor, currency, method |
| billing.payment_proof.submitted | A guardian or student attached a payment proof (photo + amount + date) to an invoice. | paymentProofId, invoiceId, studentId, amountMinor, currency, paidOn, submittedByUserId |
| billing.payment_proof.reviewed | Staff confirmed or rejected a payment proof attached to an invoice. | paymentProofId, invoiceId, studentId, status, submittedByUserId, reviewNote |
| member.invited | A new member was invited to the tenant. | invitationId, email, roleId, invitedByUserId |
| lesson.published | A lesson in a video course was published and became visible to enrolled learners. | lessonId, courseId, sectionId, title |
| lesson.completed | A learner finished a lesson (progress reached completion). | lessonId, courseId, studentId |
| course.completed | A learner completed every published lesson of a course. | courseId, studentId, lessonCount |
| course_enrollment.created | A student was enrolled in a video course. | courseEnrollmentId, courseId, studentId, status, priceMinor, currency |
| course_enrollment.dropped | A student was removed from a video course. | courseEnrollmentId, courseId, studentId, reason |
| lesson_file.added | A file was added to a lesson. | fileId, lessonId, courseId, visibility, sizeBytes |
| lesson_file.updated | A lesson file's name, description or visibility changed. | fileId, lessonId, courseId, visibility |
| lesson_file.reordered | A lesson's files were put in a new order. | lessonId, courseId, fileIds |
| lesson_file.removed | A file was deleted from a lesson. | fileId, lessonId, courseId |
| lesson.audience_changed | The groups a unit (lesson, assignment or quiz) is for changed: every group of the course, or only the listed ones. | lessonId, courseId, restricted, groupIds |
| lesson_task.saved | The task under a video lesson was created or changed. | assignmentId, lessonId, courseId, created |
| lesson_task.removed | The task under a video lesson was removed (with its submissions only after an explicit confirmation). | assignmentId, lessonId, courseId, deletedSubmissions |
| reminder.dispatched | The reminder engine sent one reminder type (class starts soon / work due tomorrow) for one session or unit, at one of the configured lead times. | kind, subjectId, recipientCount, leadKey |
| notification_type_preference.changed | A user switched one notification type on or off for themselves. | userId, typeKey, enabled |
| web_push.subscribed | A user's browser subscribed to web-push notifications. | userId, subscriptionId |
| web_push.unsubscribed | A user's browser subscription to web-push notifications was removed. | userId, subscriptionId |
| notification_rule.updated | The institute changed the rule of one notification type (who receives it, on what, when and through which channels). | typeKey, version, changedFields |
| notification_rule.reset | The institute reset one notification type's rule to the Institflow default. | typeKey |
| item_reminder_mode.changed | A unit or a group was set to follow the institute reminder rule, always remind, or never remind. | entityType, entityId, mode |
| assignment.published | An assignment unit was published and became visible to enrolled learners. | assignmentId, lessonId, courseId, sectionId, title |
| assignment.submitted | A student submitted (or resubmitted) an assignment. | assignmentId, submissionId, lessonId, courseId, studentId |
| assignment.graded | A teacher graded a student's assignment submission. | assignmentId, submissionId, lessonId, courseId, studentId, score, maxScore |
| quiz.published | A quiz unit was published and became visible to enrolled learners. | quizId, lessonId, courseId, sectionId, title |
| quiz.submitted | A student submitted a quiz attempt. | quizId, attemptId, lessonId, courseId, studentId |
| quiz.graded | A quiz attempt's score/passed was finalised (auto-graded, or a teacher finished grading its short-answer questions). | quizId, attemptId, lessonId, courseId, studentId, score, maxScore, passed |
| subscription.expiring | The tenant's paid period ends soon (sent 14, 7 and 1 days before the end). | subscriptionId, planCode, currentPeriodEnd, daysUntilExpiry |
| subscription.expired | The tenant's paid period ended. The dashboard stays open; this is a warning only. | subscriptionId, planCode, currentPeriodEnd |
| tenant.locked | The tenant was locked by an explicit platform action (manual lock or opt-in auto-lock). | subscriptionId, reason, source |
| tenant.unlocked | The tenant lock was lifted by the platform and the dashboard reopened. | subscriptionId, status |
| license.signal | A tamper signal was recorded for a self-hosted install (advisory only, never an automatic lock). | licenseId, installId, kinds, severity |
| install.update_failed | A self-hosted install failed to update and was rolled back to its previous version. | licenseId, installId, channel, appVersion, targetVersion, reason, result |
| install.updater_key_bound | This install's updater was given its own signing key for fault reports, bound on the vendor's control plane. | installId, boundAt |
| relay.install_rekeyed | A self-hosted install's relay key was replaced by its own server's enrolment and now awaits the platform owner's approval. | relayInstallId, accountId, licenseId, installId, previousStatus, status |
| lead.created | A contact-form lead arrived on the public surface. The payload carries no submitter details — fetch the lead with GET /api/v1/leads/{id} for the name, message and contact information. | leadId, source, courseId, groupId, locale, status |
| join_link.created | A join link (public, or private for one student/guardian) was created. The payload carries no token. | joinLinkId, kind, courseId, groupId, expiresAt, maxUses |
| join_link.status_changed | A join link was paused, resumed or revoked. | joinLinkId, status |
| join_link.redeemed | A join link was used: a public link produced a join request (a lead), or a private link was claimed and linked a student/guardian record to the account that opened it. | joinLinkId, kind, leadId, studentId, guardianId |
| join_request.approved | Staff approved a join request from a public link: a student was created or linked, the account was linked and, when the link named a group, the student was enrolled. | leadId, studentId, joinLinkId, enrolledGroupId |
| join_request.rejected | Staff rejected a join request from a public link. The lead is kept as rejected. | leadId, joinLinkId |
| portal_invite.declined | The invited Institflow account declined a student's or guardian's portal invitation (self-hosted installs). | invitationId |
| webhook_endpoint.secret_rotated | A webhook endpoint's signing secret was rotated. Until previousSecretExpiresAt every delivery carries two v1= signatures — one for the old secret, one for the new. | endpointId, previousSecretExpiresAt |
| support.report_received | A self-hosted install's fault report, problem or feature request reached the vendor's control plane. The payload carries no text. | reportId, kind, verification, severity, code |
| support.report_status_changed | The vendor changed the status of a fault, problem or feature request (acknowledged, resolved, declined, duplicate). | reportId, kind, previousStatus, status |
| support.report_replied | The vendor replied to a problem report or a feature request. The payload carries no text. | reportId, kind |
| support_submission.created | The institute owner wrote a problem report or a feature request to the vendor. The payload carries no text. | submissionId, kind, category |
| support_submission.status_changed | The vendor changed the status of a problem report or feature request the institute sent. The payload carries no text. | submissionId, kind, previousStatus, status |
| relay.install_updater_key_bound | A self-hosted install's updater sub-key was bound to the install's relay identity, so the updater can report faults while the API is down. | relayInstallId, accountId, installId, rebound |
Verifying the signature
Every delivery carries three headers:
X-Institflow-Signature: t=1740906000,v1=5257a869e7bfc...
X-Institflow-Timestamp: 1740906000
X-Institflow-Event: invoice.issued
X-Institflow-Delivery: 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5bv1 is hmac-sha256(secret, t + '.' + rawBody) — compute it over the exact bytes of the request body, before any JSON parsing/re-serialization reformats it, or the signature will never match.
import crypto from 'node:crypto';
function verifyInstitflowSignature(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
// The header is a comma-separated list and MAY carry several v1= values (one per live secret
// during a secret rotation), so never collapse it into an object keyed by field name.
const fields = signatureHeader.split(',').map((part) => part.trim());
const timestamp = Number(fields.find((part) => part.startsWith('t='))?.slice(2));
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
throw new Error('Signature timestamp outside tolerance — possible replay.');
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const expectedBuf = Buffer.from(expected);
const ok = fields
.filter((part) => part.startsWith('v1='))
.some((part) => {
const actual = Buffer.from(part.slice(3));
return actual.length === expectedBuf.length && crypto.timingSafeEqual(expectedBuf, actual);
});
if (!ok) {
throw new Error('Signature mismatch.');
}
}
// Express/Fastify: read the RAW body (before JSON parsing) — the signature is computed over the
// exact bytes Institflow sent, not over a re-serialized object.
app.post('/webhooks/institflow', express.raw({ type: 'application/json' }), (req, res) => {
verifyInstitflowSignature(req.body.toString('utf8'), req.header('X-Institflow-Signature'), process.env.INSTITFLOW_WEBHOOK_SECRET);
const event = JSON.parse(req.body);
// ... handle event.type / event.data
res.sendStatus(200);
});import hashlib
import hmac
import time
def verify_institflow_signature(raw_body: bytes, signature_header: str, secret: str, tolerance_seconds: int = 300) -> None:
# The header is a comma-separated list and MAY carry several "v1=" values (one per live
# secret during a secret rotation), so never collapse it into a dict keyed by field name.
fields = [part.strip() for part in signature_header.split(",")]
timestamp = int(next(part[2:] for part in fields if part.startswith("t=")))
if abs(time.time() - timestamp) > tolerance_seconds:
raise ValueError("Signature timestamp outside tolerance — possible replay.")
payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
signatures = [part[3:] for part in fields if part.startswith("v1=")]
if not any(hmac.compare_digest(expected, candidate) for candidate in signatures):
raise ValueError("Signature mismatch.")
# Flask: request.get_data() returns the raw, unparsed bytes.
@app.post("/webhooks/institflow")
def institflow_webhook():
verify_institflow_signature(request.get_data(), request.headers["X-Institflow-Signature"], os.environ["INSTITFLOW_WEBHOOK_SECRET"])
event = request.get_json()
# ... handle event["type"] / event["data"]
return "", 200Check the timestamp too
A signature alone doesn't prevent a captured request being replayed later — always reject a t more than a few minutes old (both snippets above do this).
Rotating a secret
POST /api/v1/webhooks/{id}/rotate-secret mints a replacement secret, returned exactly once. The endpoint keeps its id, its subscriptions and its whole delivery history — only the secret changes. For the overlap window (overlapSec, 24 hours by default, 7 days at most) every delivery carries two v1= signatures, one per secret, so you can roll the new value through your own deployment without a single event failing verification. Send { "overlapSec": 0 } when the old secret has actually leaked — it is retired on the spot.
Accept any matching v1=
A verifier that parses X-Institflow-Signature into a map keyed by field name keeps only the last v1= and will reject half the deliveries in a rotation window. Treat the header as a list and accept the body if any v1= matches — both snippets above do.
Discovering the event catalogue
GET /api/v1/webhooks/events needs no credential at all and returns every subscribable event with its payload field list — the exact strings events[] accepts. Read it at integration time instead of hard-coding a list that will go stale.
Retries, backoff & auto-disable
A delivery that doesn't get a 2xx back is retried up to 5 attempts total, with backoff of 1 minute, 5 minutes, 30 minutes, then 2 hours between attempts. An endpoint that fails 20 consecutive deliveries (every attempt of each, exhausted) is automatically disabled — one successful delivery resets that counter to zero. Institflow never follows redirects on delivery and times out after 5 seconds; make your handler fast and return 2xx before doing slow work.
Replaying a delivery
POST /webhooks/:id/deliveries/:deliveryId/replay resends a specific past delivery on demand — useful after fixing a bug in your handler, without waiting for the underlying event to happen again.