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

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

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

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

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

| אירוע | תיאור | שדות המטען |
| --- | --- | --- |
| 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 |

## אימות החתימה

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

```http
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 ובנייה מחדש משנים את עיצובו, אחרת החתימה לעולם לא תתאים.

```javascript
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);
});
```

```python
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` שולח שוב מסירה מסוימת מהעבר לפי דרישה — שימושי אחרי תיקון באג במטפל שלכם, בלי להמתין שהאירוע שמאחוריה יקרה שוב.
