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.

EventDescriptionPayload fields
student.createdA new student record was created.studentId, branchId, studentNumber, firstName, lastName, status
student.updatedA student's profile fields changed.studentId, changes
student.status_changedA student's lifecycle status changed (e.g. lead → active, active → withdrawn).studentId, previousStatus, status
enrollment.createdA student was enrolled into a teaching group.enrollmentId, studentId, groupId, priceMinor, currency, status
enrollment.droppedA student's enrollment in a group was dropped.enrollmentId, studentId, groupId, reason
session.cancelledA scheduled class session was cancelled.sessionId, groupId, scheduledStart, reason
attendance.markedAttendance was recorded for every student on a session's roster.sessionId, groupId, markedByUserId, absentStudentIds, counts
student.absentA single student was marked absent (or late/excused) for a session.studentId, sessionId, groupId, status, occurredOn
exam.publishedAn exam was published, making its results visible to students/guardians.examId, groupId, title, publishedAt
progress_report.sentA progress report was sent to a guardian.progressReportId, studentId, sentAt
invoice.issuedAn invoice moved from draft to issued.invoiceId, studentId, number, totalMinor, currency, dueDate, status
invoice.overdueAn issued invoice passed its due date without being paid in full.invoiceId, studentId, number, totalMinor, paidMinor, currency, dueDate
invoice.reminder_requestedA staff member asked for a payment reminder to be sent for an unpaid invoice.invoiceId, studentId, number, totalMinor, paidMinor, currency, dueDate
payment.recordedA payment was recorded against an invoice.paymentId, invoiceId, amountMinor, currency, method
member.invitedA new member was invited to the tenant.invitationId, email, roleId, invitedByUserId
lesson.publishedA lesson in a video course was published and became visible to enrolled learners.lessonId, courseId, sectionId, title
lesson.completedA learner finished a lesson (progress reached completion).lessonId, courseId, studentId
course.completedA learner completed every published lesson of a course.courseId, studentId, lessonCount
course_enrollment.createdA student was enrolled in a video course.courseEnrollmentId, courseId, studentId, status, priceMinor, currency
course_enrollment.droppedA student was removed from a video course.courseEnrollmentId, courseId, studentId, reason
subscription.expiringThe tenant's paid period ends soon (sent 14, 7 and 1 days before the end).subscriptionId, planCode, currentPeriodEnd, daysUntilExpiry
subscription.expiredThe tenant's paid period ended. The dashboard stays open; this is a warning only.subscriptionId, planCode, currentPeriodEnd
tenant.lockedThe tenant was locked by an explicit platform action (manual lock or opt-in auto-lock).subscriptionId, reason, source
tenant.unlockedThe tenant lock was lifted by the platform and the dashboard reopened.subscriptionId, status
license.signalA tamper signal was recorded for a self-hosted install (advisory only, never an automatic lock).licenseId, installId, kinds, severity
install.update_failedA self-hosted install failed to update and was rolled back to its previous version.licenseId, installId, channel, appVersion, targetVersion, reason, result
lead.createdA 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_rotatedA 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:

headers
X-Institflow-Signature: t=1740906000,v1=5257a869e7bfc...
X-Institflow-Timestamp: 1740906000
X-Institflow-Event: invoice.issued
X-Institflow-Delivery: 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5b

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

verify.js
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);
});
verify.py
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 "", 200

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