الويب هوك (Webhooks)
الويب هوك تُخبر نظامك بما حدث في Institflow — فاتورة صدرت، طالب تغيّب، دفعة وصلت — دون أن تستطلع عن ذلك بنفسك. كل تعديل على البيانات يُخزَّن في جدول صادر (outbox) ضمن نفس معاملة قاعدة البيانات التي أحدثت التغيير، لذا لا يمكن أبدًا أن تُرسَل محاولة تسليم لشيء لم يحدث فعليًا.
إدارة نقاط الاستقبال
/api/v1/webhooks (يتطلّب صلاحية integration.manage) — أنشئ نقطة استقبال بـ url وقائمة events التي تريد الاشتراك بها؛ السرّ المستخدَم لتوقيع محاولات التسليم يُعرض مرّة واحدة فقط عند الإنشاء. اختبار سريع (POST /webhooks/:id/test) يرسل طلبًا موقّعًا حقيقيًا إلى url فورًا، دون انتظار حدث حقيقي، لتتحقّق من مُعالِجك قبل الانطلاق الفعلي. انظر المرجع الكامل في مرجع مفاتيح 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 |
التحقّق من التوقيع
كل محاولة تسليم تحمل ثلاث ترويسات:
X-Institflow-Signature: t=1740906000,v1=5257a869e7bfc...
X-Institflow-Timestamp: 1740906000
X-Institflow-Event: invoice.issued
X-Institflow-Delivery: 0190a1b2-c3d4-7e5f-8a9b-0c1d2e3f4a5bv1 هو hmac-sha256(secret, t + '.' + rawBody) — احسبه على البايتات الفعلية لمحتوى الطلب، قبل أي تحليل/إعادة تسلسل JSON يعيد تنسيقه، وإلا لن يتطابق التوقيع أبدًا.
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 "", 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 يعيد إرسال محاولة تسليم محدّدة من الماضي عند الطلب — مفيد بعد إصلاح خلل في مُعالِجك، دون انتظار وقوع الحدث الأصلي مجدّدًا.