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

  1. 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.
  2. 2The machine on the school network with a fixed IP address (ZKTeco: Menu › Comm. › Ethernet; the default port is 4370).
  3. 3A PC that stays on during school hours, with Python 3.9+ or Node.js 18+.
  4. 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.

curl
# 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()

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 }

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 need out.
  • 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.
Next recipeSMS and WhatsApp to parents on absence