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