Webhooks
Events, pushed to you
Instead of asking School360 every few minutes, let it call you: a student marked absent, a fee payment received, vouchers issued, results published. Each event is a signed HTTPS POST with JSON, retried for two days if your system is down.
Adding an endpoint
Webhooks come with the API (the Enterprise package). The school's owner or admin adds endpoints in School Setup › Integrations › Webhooks: the address (https), a description, the events it should get and, optionally, one campus. School360 then shows the endpoint's signing secret (whsec_…) — keep it like a password; it can be revealed again there or rolled (replaced) at any time. A school can have up to 20 endpoints.
What is sent
Every event is a POST with Content-Type: application/json and this body. data depends on the event (below); its field names match the API's resources, and new fields may be added (never removed or renamed), so ignore what you don't know. Bodies are at most 256 KB.
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "webhook.test",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"message": "This is a test event from School360.",
"endpointId": 12
}
}Headers sent with each event:
- School360-Event
- the event name, e.g. student.absent
- School360-Delivery
- the event's id (the same as id in the body); the same for retries and resends
- School360-Signature
- t=<unix time>,v1=<signature> (see Checking the signature)
- User-Agent
- School360-Webhooks/1.0
Events
Pick the events an endpoint gets when you add it. An endpoint limited to a campus only gets events about that campus (batch events list only that campus's vouchers or classes).
Students
student.admitted
A new student is admitted: on the student form, by enrolling an application (in the portal, on School360 Desktop or through POST /applications/{id}/enrol). With the family's phone numbers.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "student.admitted",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"student": {
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14,
"gender": "female",
"dob": "2016-03-14",
"status": "active",
"admissionDate": "2026-10-08",
"cardNo": "0004417365",
"updatedAt": "2026-10-08T05:15:03.000Z"
},
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
},
"applicationId": 88
}
}student.updated
A student's details, class, section or status (other than leaving) changed, in the portal or through PATCH /students/{id}. Coalesced: at most one event per student every 5 minutes, sent at the end of those 5 minutes with the student as they are then.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "student.updated",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"student": {
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14,
"gender": "female",
"dob": "2016-03-14",
"status": "active",
"admissionDate": "2026-04-01",
"cardNo": "0004417365",
"updatedAt": "2026-10-08T05:15:03.000Z"
}
}
}student.left
A student is marked left or withdrawn, or a leaving certificate marks them left.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "student.left",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"student": {
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": null,
"gender": "female",
"dob": "2016-03-14",
"status": "left",
"admissionDate": "2023-04-03",
"cardNo": null,
"updatedAt": "2026-10-08T05:15:03.000Z"
},
"status": "left",
"note": "Moved to another city"
}
}student.promoted
The year-end promotion was run: one event for the whole school, with the sessions and how many were promoted, repeated, graduated, left and admitted. Read the students again (GET /students?updatedSince=) for their new classes.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "student.promoted",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"id": 4,
"fromYear": {
"id": 3,
"name": "2026-27"
},
"toYear": {
"id": 4,
"name": "2027-28"
},
"counts": {
"promoted": 412,
"repeated": 6,
"graduated": 38,
"left": 9,
"admitted": 57
},
"runAt": "2027-03-31T10:02:11.000Z"
}
}Admissions
application.received
A new admission application: taken in the portal, sent from the school's website, taken on School360 Desktop or through POST /applications. With the parents' phones for a quick follow-up.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "application.received",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"id": 88,
"number": "ADM-2026-0088",
"status": "inquiry",
"yearId": 3,
"classId": 2,
"className": "Prep",
"campusId": 1,
"firstName": "Hamza",
"lastName": "Siddiqui",
"gender": "male",
"dob": "2021-05-02",
"source": "website",
"appliedOn": "2026-10-08",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567",
"email": "imran@example.com"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"familyId": 233,
"studentId": null
}
}application.status_changed
An application moves on: form received (applied), test scheduled, offered (approved), refused (rejected), reopened or enrolled. previousStatus says where it was.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "application.status_changed",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"application": {
"id": 88,
"number": "ADM-2026-0088",
"status": "approved",
"yearId": 3,
"classId": 2,
"className": "Prep",
"campusId": 1,
"firstName": "Hamza",
"lastName": "Siddiqui",
"gender": "male",
"dob": "2021-05-02",
"source": "website",
"appliedOn": "2026-10-08",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567",
"email": "imran@example.com"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"familyId": 233,
"studentId": null
},
"previousStatus": "applied",
"status": "approved"
}
}Attendance
student.absent
A student is marked absent in a register (in the portal or on School360 Desktop). One event per student per day, with the family's phone numbers for SMS or WhatsApp.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "student.absent",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"date": "2026-10-08",
"status": "absent",
"student": {
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14
},
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
}
}
}student.late
A student is marked late in a register, or arrives after the late time by a biometric punch. One event per student per day, with the family's phone numbers.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "student.late",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"date": "2026-10-08",
"status": "late",
"student": {
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14
},
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
}
}
}attendance.register_saved
A section's register is marked or corrected (in the portal, on School360 Desktop or through POST /attendance/registers), with each exception and the day's counts. Biometric punches don't send it.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "attendance.register_saved",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"id": 9120,
"date": "2026-10-08",
"sectionId": 19,
"sectionName": "A",
"classId": 7,
"className": "Class 5",
"campusId": 1,
"markedBy": "Nadia Khan",
"markedAt": "2026-10-08T03:20:41.000Z",
"onRoll": 32,
"counts": {
"present": 30,
"absent": 1,
"late": 1,
"leave": 0,
"halfDay": 0
},
"marks": [
{
"studentId": 412,
"status": "absent",
"remark": null
},
{
"studentId": 415,
"status": "late",
"remark": "Bus late"
}
]
}
}leave.requested
A leave request for a student or a member of staff is made and waits for a decision.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "leave.requested",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"leave": {
"id": 301,
"kind": "student",
"type": "sick",
"from": "2026-10-09",
"to": "2026-10-10",
"days": 2,
"reason": "Fever",
"status": "pending",
"requestedOn": "2026-10-08",
"requestedBy": "Imran Siddiqui",
"decidedOn": null,
"comment": null
},
"person": {
"type": "student",
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14
}
}
}leave.decided
A leave request is approved or refused.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "leave.decided",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"leave": {
"id": 302,
"kind": "staff",
"type": "casual",
"from": "2026-10-12",
"to": "2026-10-12",
"days": 1,
"reason": "Family wedding",
"status": "approved",
"requestedOn": "2026-10-06",
"requestedBy": "Nadia Khan",
"decidedOn": "2026-10-07",
"comment": null
},
"person": {
"type": "staff",
"id": 17,
"code": "STF-0017",
"firstName": "Nadia",
"lastName": "Khan",
"campusId": 1
}
}
}Fees
fee.payment_received
A fee receipt is made: at the fee counter, on School360 Desktop or through POST /payments.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "fee.payment_received",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"id": 981,
"number": "RCT-2026-00981",
"amount": 8500,
"lateFine": 0,
"familyId": 233,
"campusId": 1,
"method": "ibft",
"reference": "FT26100812345",
"receivedOn": "2026-10-08",
"createdAt": "2026-10-08T05:15:02.000Z",
"lines": [
{
"voucherId": 5120,
"voucherNumber": "FV-2026-10-00123",
"studentId": 412,
"month": "2026-10",
"amount": 8500,
"fine": 0
}
],
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
}
}
}fee.vouchers_issued
A month's vouchers are issued. One event per issue run; up to 500 vouchers are listed, with more: true when there were more (fetch them with GET /vouchers?month=).
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "fee.vouchers_issued",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"month": "2026-10",
"count": 2,
"vouchers": [
{
"id": 5120,
"number": "FV-2026-10-00123",
"studentId": 412,
"familyId": 233,
"classId": 7,
"sectionId": 19,
"campusId": 1,
"total": 8500,
"dueOn": "2026-10-10"
},
{
"id": 5121,
"number": "FV-2026-10-00124",
"studentId": 413,
"familyId": 233,
"classId": 4,
"sectionId": 11,
"campusId": 1,
"total": 7200,
"dueOn": "2026-10-10"
}
],
"more": false
}
}fee.voucher_overdue
An unpaid voucher's due date has passed. Sent once per voucher, at 8 am school time the day after it fell due, with the family's phone numbers.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "fee.voucher_overdue",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"voucher": {
"id": 5120,
"number": "FV-2026-10-00123",
"studentId": 412,
"familyId": 233,
"classId": 7,
"sectionId": 19,
"campusId": 1,
"month": "2026-10",
"issuedOn": "2026-10-01",
"dueOn": "2026-10-10",
"total": 8500,
"paid": 0,
"balance": 8500,
"fineAmount": 500,
"lateFine": 0,
"status": "overdue"
},
"student": {
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14
},
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
}
}
}Exams
exam.results_published
An exam's results are published to families.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "exam.results_published",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"id": 31,
"name": "Mid-Term 2026",
"type": "mid-term",
"academicYearId": 3,
"termId": 5,
"startDate": "2026-09-21",
"endDate": "2026-09-30",
"publishedOn": "2026-10-08",
"classIds": [
7,
8
],
"classes": [
{
"id": 7,
"name": "Class 5",
"campusId": 1
},
{
"id": 8,
"name": "Class 6",
"campusId": 1
}
]
}
}Notices
notice.published
A notice goes live on the notice board (straight away, or at its scheduled time).
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "notice.published",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"id": 57,
"title": "School closed on Friday",
"category": "holiday",
"audience": "everyone",
"classIds": [],
"campusId": null,
"pinned": true,
"text": "The school will be closed on Friday 10 October for the public holiday.",
"publishFrom": null,
"publishUntil": "2026-10-11T00:00:00.000Z",
"publishedAt": "2026-10-08T04:30:00.000Z"
}
}Staff
staff.joined
A new member of staff is added in Staff & HR. Work contacts only: never pay or CNIC.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "staff.joined",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"staff": {
"id": 58,
"code": "STF-0058",
"firstName": "Usman",
"lastName": "Raza",
"gender": "male",
"category": "academic",
"designation": {
"id": 4,
"name": "Teacher"
},
"department": {
"id": 2,
"name": "Science"
},
"campusId": 1,
"employmentType": "full-time",
"phone": "0333-5550101",
"email": "usman.raza@example.com",
"cardNo": "0004417401",
"joinedOn": "2026-10-08",
"status": "active"
}
}
}staff.left
A member of staff's employment ends (resigned, retired, let go), with the last day.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "staff.left",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"staff": {
"id": 21,
"code": "STF-0021",
"firstName": "Farah",
"lastName": "Ali",
"gender": "female",
"category": "non-academic",
"designation": {
"id": 9,
"name": "Receptionist"
},
"department": {
"id": 5,
"name": "Administration"
},
"campusId": 1,
"employmentType": "full-time",
"phone": "0300-5550199",
"email": "",
"cardNo": null,
"joinedOn": "2022-08-01",
"status": "left"
},
"leftOn": "2026-10-31",
"reason": "resigned"
}
}Gate
pickup.handed
Children are handed over (or dropped off) at the gate: one event per handover, with the collector and each child's family phone numbers.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "pickup.handed",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"result": "handed",
"at": "2026-10-08T08:35:12.000Z",
"direction": "pickup",
"campusId": 1,
"collector": {
"id": 77,
"name": "Akram Transport",
"kind": "transporter",
"vehicleNo": "LEA-1234"
},
"students": [
{
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14,
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
}
}
]
}
}pickup.denied
The gate refused to hand children over, with the school's reason and the guard's note.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "pickup.denied",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"result": "denied",
"at": "2026-10-08T08:36:40.000Z",
"direction": "pickup",
"campusId": 1,
"collector": {
"id": null,
"name": "Unknown driver",
"kind": null,
"vehicleNo": null
},
"reason": "not-authorised",
"note": "Not on the family's list",
"students": [
{
"id": 412,
"admissionNo": "ADM-2026-00412",
"firstName": "Ayesha",
"lastName": "Siddiqui",
"classId": 7,
"className": "Class 5",
"sectionId": 19,
"sectionName": "A",
"campusId": 1,
"rollNo": 14,
"family": {
"id": 233,
"code": "FAM-00233",
"primaryContact": "father",
"father": {
"name": "Imran Siddiqui",
"phone": "0300-1234567"
},
"mother": {
"name": "Sana Imran",
"phone": "0321-7654321"
},
"guardian": null,
"phone": "0300-1234567"
}
}
]
}
}Other
webhook.test
Sent when someone presses Send test event. Not subscribable.
Example
{
"id": "6f1d2c3e-8a4b-4c5d-9e6f-7a8b9c0d1e2f",
"event": "webhook.test",
"createdAt": "2026-10-08T05:15:03.412Z",
"school": {
"code": "SCH00001",
"name": "Axiom School"
},
"data": {
"message": "This is a test event from School360.",
"endpointId": 12
}
}Checking the signature
Check every event before trusting it. Compute an HMAC-SHA256 of "<t>.<raw body>" with the endpoint's secret and compare it, in constant time, with v1 from the School360-Signature header. Use the raw body exactly as received (before parsing JSON), and refuse events whose t is more than 5 minutes from your clock, so an old event can't be replayed.
School360-Signature: t=1791443703,v1=5f0c8e…c2a1
signed = HMAC-SHA256(secret, t + "." + rawBody) → hex# Check a saved event by hand. body.json holds the raw body exactly as received.
SECRET="whsec_…"
HEADER="t=1791443703,v1=5f0c8e…" # the School360-Signature header
T=$(echo "$HEADER" | sed -E 's/.*t=([0-9]+).*/\1/')
V1=$(echo "$HEADER" | sed -E 's/.*v1=([0-9a-f]+).*/\1/')
EXPECTED=$(printf '%s.%s' "$T" "$(cat body.json)" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
[ "$EXPECTED" = "$V1" ] && echo "valid" || echo "NOT valid"// Node.js (Express): keep the raw body for the signature
import crypto from "node:crypto"
import express from "express"
const SECRET = process.env.SCHOOL360_WEBHOOK_SECRET // whsec_…
const app = express()
function verified(header = "", body) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)))
const expected = crypto.createHmac("sha256", SECRET).update(`${parts.t}.${body}`).digest("hex")
return (
parts.v1?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - Number(parts.t)) < 300 // refuse replays of old events
)
}
app.post("/school360/webhook", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8")
if (!verified(req.get("School360-Signature"), body)) return res.status(400).send("bad signature")
res.sendStatus(204) // answer first, then do the work
queue.add(JSON.parse(body)) // skip event.id if you've seen it before
})
app.listen(3000)<?php
// PHP: read the raw body before anything parses it
$secret = getenv('SCHOOL360_WEBHOOK_SECRET'); // whsec_…
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_SCHOOL360_SIGNATURE'] ?? ''), $parts);
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $body, $secret);
if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) ($parts['t'] ?? 0)) > 300) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($body, true);
http_response_code(204);
// then: skip $event['id'] if already handled, and do the work# Python (Flask)
import hashlib, hmac, json, os, time
from flask import Flask, request
SECRET = os.environ["SCHOOL360_WEBHOOK_SECRET"].encode() # whsec_…
app = Flask(__name__)
@app.post("/school360/webhook")
def school360():
body = request.get_data() # raw bytes
parts = dict(p.split("=", 1) for p in request.headers.get("School360-Signature", "").split(",") if "=" in p)
expected = hmac.new(SECRET, parts.get("t", "").encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")) or abs(time.time() - int(parts.get("t", 0))) > 300:
return "bad signature", 400
event = json.loads(body)
# skip event["id"] if already handled, then queue the work
return "", 204Answers, timeouts and retries
Answer with any 2xx status within 10 seconds; the body of your answer is ignored (the first 1 KB is kept in the delivery log). Anything else — another status, a timeout, a refused connection — is retried after 1 minute, 5 minutes, 15 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours, then given up. Redirects aren't followed. An endpoint that fails without a single success for 3 days is turned off, and the school's owner gets an email; turn it back on in Settings once it's fixed. Endpoints must be public https:// addresses: private and local network addresses are refused.
Answer quickly and do the work afterwards (put the event on your own queue). An SMS gateway that takes 15 seconds to answer would be retried and send the SMS twice.
Duplicates and order
An event can arrive more than once (a retry after your answer was lost, or someone pressing Resend), so use School360-Delivery (the body's id) to skip ones you've already handled. Events aren't guaranteed to arrive in order; use createdAt and the data's own dates. School360 itself sends student.absent and student.late once per student per day, fee.voucher_overdue once per voucher, and student.updated at most once per student every 5 minutes.
Testing
Send test event on an endpoint (Settings › Integrations › Webhooks) sends webhook.test at once, so you can check your signature code. The delivery log shows every attempt with its status code, time and your answer, and Resend sends an event again with the same id. For local development, a tunnel (ngrok, Cloudflare Tunnel) gives your laptop an https address.
Ready-made receivers: SMS and WhatsApp on absence and accounting entries per payment.