Webhooks

Webhooks מספרים למערכת שלכם על דברים שקרו ב-Institflow — חשבונית הונפקה, תלמיד נעדר, תשלום נכנס — בלי שתצטרכו לתשאל בעצמכם. כל שינוי בדומיין נשמר לטבלת outbox באותה טרנזקציה שבה בוצע השינוי עצמו, ולכן לעולם לא תישלח מסירה על משהו שלא באמת קרה.

ניהול נקודות קבלה

/api/v1/webhooks (דורש integration.manage) — צרו נקודת קבלה עם url ורשימת ה-events שהיא רוצה; הסוד שמשמש לחתימת המסירות מוצג פעם אחת בדיוק, ביצירה. פינג בדיקה (POST /webhooks/:id/test) שולח מיד בקשה חתומה אמיתית ל-url שלכם, בלי להמתין לאירוע אמיתי, כדי שתוכלו לוודא שהמטפל שלכם עובד לפני עלייה לאוויר. לכל השדות ראו את רפרנס מפתחות ה-API וה-Webhooks.

קטלוג האירועים

כל סוגי האירועים שנקודת הקבלה שלכם יכולה להירשם אליהם, נוצרים מ-packages/shared/src/events.ts. גוף ה-JSON של כל מסירה הוא { id, type, occurredAt, tenantId, data } — השדות שלמטה הם מה שמופיע בתוך data.

אירועתיאורשדות המטען
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
billing.payment_proof.submittedA guardian or student attached a payment proof (photo + amount + date) to an invoice.paymentProofId, invoiceId, studentId, amountMinor, currency, paidOn, submittedByUserId
billing.payment_proof.reviewedStaff confirmed or rejected a payment proof attached to an invoice.paymentProofId, invoiceId, studentId, status, submittedByUserId, reviewNote
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
lesson_file.addedA file was added to a lesson.fileId, lessonId, courseId, visibility, sizeBytes
lesson_file.updatedA lesson file's name, description or visibility changed.fileId, lessonId, courseId, visibility
lesson_file.reorderedA lesson's files were put in a new order.lessonId, courseId, fileIds
lesson_file.removedA file was deleted from a lesson.fileId, lessonId, courseId
lesson.audience_changedThe 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.savedThe task under a video lesson was created or changed.assignmentId, lessonId, courseId, created
lesson_task.removedThe task under a video lesson was removed (with its submissions only after an explicit confirmation).assignmentId, lessonId, courseId, deletedSubmissions
reminder.dispatchedThe 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.changedA user switched one notification type on or off for themselves.userId, typeKey, enabled
web_push.subscribedA user's browser subscribed to web-push notifications.userId, subscriptionId
web_push.unsubscribedA user's browser subscription to web-push notifications was removed.userId, subscriptionId
notification_rule.updatedThe institute changed the rule of one notification type (who receives it, on what, when and through which channels).typeKey, version, changedFields
notification_rule.resetThe institute reset one notification type's rule to the Institflow default.typeKey
item_reminder_mode.changedA unit or a group was set to follow the institute reminder rule, always remind, or never remind.entityType, entityId, mode
assignment.publishedAn assignment unit was published and became visible to enrolled learners.assignmentId, lessonId, courseId, sectionId, title
assignment.submittedA student submitted (or resubmitted) an assignment.assignmentId, submissionId, lessonId, courseId, studentId
assignment.gradedA teacher graded a student's assignment submission.assignmentId, submissionId, lessonId, courseId, studentId, score, maxScore
quiz.publishedA quiz unit was published and became visible to enrolled learners.quizId, lessonId, courseId, sectionId, title
quiz.submittedA student submitted a quiz attempt.quizId, attemptId, lessonId, courseId, studentId
quiz.gradedA 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.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
install.updater_key_boundThis install's updater was given its own signing key for fault reports, bound on the vendor's control plane.installId, boundAt
relay.install_rekeyedA 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.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
join_link.createdA 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_changedA join link was paused, resumed or revoked.joinLinkId, status
join_link.redeemedA 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.approvedStaff 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.rejectedStaff rejected a join request from a public link. The lead is kept as rejected.leadId, joinLinkId
portal_invite.declinedThe invited Institflow account declined a student's or guardian's portal invitation (self-hosted installs).invitationId
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
support.report_receivedA 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_changedThe vendor changed the status of a fault, problem or feature request (acknowledged, resolved, declined, duplicate).reportId, kind, previousStatus, status
support.report_repliedThe vendor replied to a problem report or a feature request. The payload carries no text.reportId, kind
support_submission.createdThe institute owner wrote a problem report or a feature request to the vendor. The payload carries no text.submissionId, kind, category
support_submission.status_changedThe 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_boundA 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

אימות החתימה

כל מסירה נושאת שלוש כותרות:

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 הוא hmac-sha256(secret, t + '.' + rawBody) — חשבו אותו על הבתים המדויקים של גוף הבקשה, לפני שפענוח JSON ובנייה מחדש משנים את עיצובו, אחרת החתימה לעולם לא תתאים.

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

בדקו גם את חותמת הזמן

חתימה לבדה אינה מונעת שידור חוזר של בקשה שנלכדה — דחו תמיד t שישן ביותר מכמה דקות (שני הקטעים שלמעלה עושים זאת).

החלפת סוד

POST /api/v1/webhooks/{id}/rotate-secret מנפיק סוד חלופי, שמוחזר פעם אחת בדיוק. נקודת הקבלה שומרת על המזהה שלה, על המנויים שלה ועל כל היסטוריית המסירות — רק הסוד משתנה. במהלך חלון החפיפה (overlapSec, 24 שעות כברירת מחדל, 7 ימים לכל היותר) כל מסירה נושאת שתי חתימות v1=, אחת לכל סוד, כך שתוכלו להטמיע את הערך החדש בפריסה שלכם בלי שאף אירוע ייכשל באימות. שלחו { "overlapSec": 0 } כשהסוד הישן דלף באמת — הוא מוצא משימוש במקום.

קבלו כל v1= שמתאים

מאמת שמפרק את X-Institflow-Signature למפה לפי שמות שדות שומר רק את ה-v1= האחרון, ויידחה מחצית מהמסירות בחלון החלפה. התייחסו לכותרת כאל רשימה וקבלו את הגוף אם כלשהו v1= מתאים — שני הקטעים שלמעלה עושים זאת.

גילוי קטלוג האירועים

GET /api/v1/webhooks/events אינו דורש שום אישור גישה ומחזיר כל אירוע שניתן להירשם אליו יחד עם רשימת שדות המטען שלו — בדיוק המחרוזות ש-events[] מקבל. קראו אותו בזמן האינטגרציה במקום לקבע רשימה שתתיישן.

ניסיונות חוזרים והשבתה אוטומטית

מסירה שלא מקבלת 2xx בחזרה נשלחת שוב עד 5 ניסיונות בסך הכול, בהשהיות של דקה, 5 דקות, 30 דקות ואז שעתיים בין ניסיון לניסיון. נקודת קבלה שנכשלת ב-20 מסירות רצופות (כל הניסיונות של כל אחת מהן מוצו) מושבתת אוטומטית — מסירה מוצלחת אחת מאפסת את המונה הזה. Institflow לעולם לא עוקבת אחרי הפניות במסירה, ומפסיקה להמתין אחרי 5 שניות; עשו את המטפל שלכם מהיר והחזירו 2xx לפני שאתם ניגשים לעבודה איטית.

שליחה חוזרת של מסירה

POST /webhooks/:id/deliveries/:deliveryId/replay שולח שוב מסירה מסוימת מהעבר לפי דרישה — שימושי אחרי תיקון באג במטפל שלכם, בלי להמתין שהאירוע שמאחוריה יקרה שוב.