Recipes
ZKTeco and other biometric machines
Send fingerprint, face and card punches from the school's attendance machines to School360: students are marked present or late in their register, staff get their check-in and check-out.
Goal
Children and staff punch in at the gate as they do today. A small bridge program on a PC at school reads the machine every few minutes and posts the punches to POST /v1/attendance/punches. Teachers open a register that's already marked; absences stay theirs to confirm.
What you need
- 1A key with attendance: write (and staff: read, so the bridge can tell staff from students), ideally limited to the school PC's IP address.
- 2The machine on the school network with a fixed IP address (ZKTeco: Menu › Comm. › Ethernet; the default port is 4370).
- 3A PC that stays on during school hours, with Python 3.9+ or Node.js 18+.
- 4Each student's and staff member's machine user id (or RFID card number) saved in School360 as their Attendance card no.
1. Link people to the machine
Machines know people by the user id they were enrolled with (or the number on their RFID card). Put that number in School360 as the person's Attendance card no.: on the student or staff form, in the Excel / CSV import, or through the API. Card numbers are unique among current students and among current staff.
# Give a student the id they're enrolled under on the machine (or their RFID card's number).
# Easier for many at once: the Attendance card no. column of the Excel / CSV student import.
curl -X PATCH https://api.school360.cloud/v1/students/412 \
-H "Authorization: Bearer $SCHOOL360_KEY" \
-H "Content-Type: application/json" \
-d '{ "cardNo": "1042" }'2. Run the bridge
The bridge connects to the machine, reads its log, keeps the last few hours and posts them in batches of up to 500. Run it every 2–5 minutes with Windows Task Scheduler or cron.
# bridge.py: reads a ZKTeco machine on the school's network and sends new punches to School360.
# pip install pyzk requests Run every 2–5 minutes (Task Scheduler / cron) on a PC that stays on.
import os
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests
from zk import ZK
DEVICE_IP = "192.168.1.201" # the machine's address (Menu › Comm. › Ethernet)
DEVICE_ID = "main-gate" # any name: kept with each punch
SCHOOL_TZ = ZoneInfo("Asia/Karachi") # GET /school → timezone
LOOKBACK = timedelta(hours=3) # resend a little history: duplicates are ignored
api = requests.Session()
api.headers["Authorization"] = f"Bearer {os.environ['SCHOOL360_KEY']}" # attendance:write + staff:read
def staff_cards():
"""Card numbers / device user ids that belong to staff (everyone else is a student)."""
cards, cursor = set(), None
while True:
res = api.get("https://api.school360.cloud/v1/staff", params={"status": "active", "limit": 200, **({"cursor": cursor} if cursor else {})}, timeout=30)
res.raise_for_status()
page = res.json()
cards.update(s["cardNo"] for s in page["data"] if s.get("cardNo"))
if not page["hasMore"]:
return cards
cursor = page["nextCursor"]
def main():
conn = ZK(DEVICE_IP, port=4370, timeout=10).connect()
try:
logs = conn.get_attendance()
finally:
conn.disconnect()
since = datetime.now(SCHOOL_TZ) - LOOKBACK
staff = staff_cards()
punches = []
for log in logs:
at = log.timestamp.replace(tzinfo=SCHOOL_TZ) # the machine's clock has no time zone
if at < since:
continue
user = str(log.user_id)
punches.append({
"at": at.isoformat(),
"personType": "staff" if user in staff else "student",
"identifier": {"cardNo": user},
"direction": "out" if log.punch == 1 else "in",
"deviceId": DEVICE_ID,
})
for i in range(0, len(punches), 500): # at most 500 a request
res = api.post("https://api.school360.cloud/v1/attendance/punches", json={"punches": punches[i:i + 500]}, timeout=60)
res.raise_for_status()
out = res.json()
print(f"applied {out['applied']}, duplicates {out['duplicates']}, unknown {out['unknown']}, outside window {out['outsideWindow']}")
for r in out["results"]:
if r["status"] == "unknown_person":
print("not enrolled in School360:", punches[i + r["index"]]["identifier"])
if __name__ == "__main__":
main()// bridge.mjs: reads a ZKTeco machine on the school's network and sends new punches to School360.
// npm install node-zklib Run every 2–5 minutes (Task Scheduler / cron) on a PC that stays on,
// with the PC's time zone set to the school's (the machine's clock has no time zone).
import ZKLib from "node-zklib"
const DEVICE_IP = "192.168.1.201"
const DEVICE_ID = "main-gate"
const LOOKBACK_MS = 3 * 60 * 60 * 1000 // resend a little history: duplicates are ignored
const headers = { Authorization: `Bearer ${process.env.SCHOOL360_KEY}`, "Content-Type": "application/json" }
async function staffCards() {
const cards = new Set()
let cursor = null
do {
const res = await fetch(`https://api.school360.cloud/v1/staff?status=active&limit=200${cursor ? `&cursor=${cursor}` : ""}`, { headers })
const page = await res.json()
if (!res.ok) throw new Error(`${res.status} ${page.error.code}`)
for (const s of page.data) if (s.cardNo) cards.add(s.cardNo)
cursor = page.hasMore ? page.nextCursor : null
} while (cursor)
return cards
}
const zk = new ZKLib(DEVICE_IP, 4370, 10000, 4000)
await zk.createSocket()
const { data: logs } = await zk.getAttendances()
await zk.disconnect()
const since = Date.now() - LOOKBACK_MS
const staff = await staffCards()
const punches = logs
.filter((l) => new Date(l.recordTime).getTime() >= since)
.map((l) => ({
at: new Date(l.recordTime).toISOString(),
personType: staff.has(String(l.deviceUserId)) ? "staff" : "student",
identifier: { cardNo: String(l.deviceUserId) },
direction: "in", // this library doesn't report in/out: the first punch of the day marks attendance
deviceId: DEVICE_ID,
}))
for (let i = 0; i < punches.length; i += 500) {
const res = await fetch("https://api.school360.cloud/v1/attendance/punches", { method: "POST", headers, body: JSON.stringify({ punches: punches.slice(i, i + 500) }) })
const out = await res.json()
if (!res.ok) throw new Error(`${res.status} ${out.error.code}`)
console.log(`applied ${out.applied}, duplicates ${out.duplicates}, unknown ${out.unknown}, outside window ${out.outsideWindow}`)
}Re-sending is safe: each punch (person, time, direction) counts once, so the bridge doesn't need to remember what it sent. Sending the last 3 hours each run also covers a PC that was off for a while.
3. The request
Under the hood it's one call. Students can also be identified by admissionNo, or by rollNo with sectionId; staff by staffCode.
curl -X POST https://api.school360.cloud/v1/attendance/punches \
-H "Authorization: Bearer $SCHOOL360_KEY" \
-H "Content-Type: application/json" \
-d '{ "punches": [
{ "at": "2026-10-08T07:52:10+05:00", "personType": "student", "identifier": { "cardNo": "0004417365" }, "direction": "in", "deviceId": "gate-1" },
{ "at": "2026-10-08T07:40:02+05:00", "personType": "staff", "identifier": { "staffCode": "STF-0007" }, "direction": "in", "deviceId": "gate-1" }
] }'
# → { "results": [{ "index": 0, "status": "applied", … }, …], "applied": 2, "duplicates": 0, "unknown": 0, "outsideWindow": 0 }const punches = [
{ at: "2026-10-08T07:52:10+05:00", personType: "student", identifier: { cardNo: "0004417365" }, direction: "in", deviceId: "gate-1" },
{ at: "2026-10-08T07:40:02+05:00", personType: "staff", identifier: { staffCode: "STF-0007" }, direction: "in", deviceId: "gate-1" },
]
const res = await fetch("https://api.school360.cloud/v1/attendance/punches", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SCHOOL360_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ punches }), // up to 500 at a time
})
const out = await res.json()
if (!res.ok) throw new Error(`${res.status} ${out.error.code}`)
// applied and duplicate are both fine; the others need a look
for (const r of out.results) {
if (r.status === "unknown_person" || r.status === "outside_window") console.warn(punches[r.index], r.status, r.message)
}<?php
$punches = [
['at' => '2026-10-08T07:52:10+05:00', 'personType' => 'student', 'identifier' => ['cardNo' => '0004417365'], 'direction' => 'in', 'deviceId' => 'gate-1'],
['at' => '2026-10-08T07:40:02+05:00', 'personType' => 'staff', 'identifier' => ['staffCode' => 'STF-0007'], 'direction' => 'in', 'deviceId' => 'gate-1'],
];
$ch = curl_init('https://api.school360.cloud/v1/attendance/punches');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('SCHOOL360_KEY'), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['punches' => $punches]), // up to 500 at a time
]);
$out = json_decode(curl_exec($ch), true);
curl_close($ch);
foreach ($out['results'] as $r) {
if (in_array($r['status'], ['unknown_person', 'outside_window'], true)) {
error_log("punch {$r['index']}: {$r['status']} {$r['message']}");
}
}import os
import requests
punches = [
{"at": "2026-10-08T07:52:10+05:00", "personType": "student", "identifier": {"cardNo": "0004417365"}, "direction": "in", "deviceId": "gate-1"},
{"at": "2026-10-08T07:40:02+05:00", "personType": "staff", "identifier": {"staffCode": "STF-0007"}, "direction": "in", "deviceId": "gate-1"},
]
res = requests.post(
"https://api.school360.cloud/v1/attendance/punches",
headers={"Authorization": f"Bearer {os.environ['SCHOOL360_KEY']}"},
json={"punches": punches}, # up to 500 at a time
timeout=30,
)
res.raise_for_status()
for r in res.json()["results"]:
if r["status"] in ("unknown_person", "outside_window"):
print("check:", punches[r["index"]], r["status"], r["message"])Time zones and clocks
Machine clocks have no time zone: a log says 07:52, not 07:52 in Karachi. Attach the school's offset before sending (2026-10-08T07:52:10+05:00), or convert to UTC (…Z). The school's zone is in GET /school (timezone).
Keep the machine's clock right (sync it from the PC weekly): a fast clock can make on-time children late.
What each punch did
The answer lists every punch with its status:
applied: it changed a register or a staff day (the first "in" of the day marks a student present, or late after the school's late time).duplicate: it was sent before; nothing changed. Expected when you resend history.unknown_person: nobody has this card number or id. Fix the enrolment and it will apply on the next run.outside_window: older than 7 days, in the future (check the machine's clock), or on a day the school is closed.
Pitfalls
- A punch never marks a child absent. Absence stays the teacher's call in the register (and sends the student.absent webhook).
- A mark someone already made is kept, except absent, which becomes present or late when the child punches in later.
- If your library doesn't report in/out, send everything as
in: students are marked by their first punch; staff check-out times needout. - Don't post one request per punch: batch them (up to 500), or you'll meet the rate limit at the morning rush.
- Machines that store many thousands of logs are slow to read: clear old logs on the machine once a term, after they're in School360.