Guide
Polling for changes
Webhooks push events to you. When your tool can't receive them (a spreadsheet, a nightly job behind a firewall), poll this feed instead: one cheap call tells you what to read again.
How it works
Tools that can't take webhooks (Zapier, Google Sheets, a nightly job) can poll GET /changes. Call it once without since to get a cursor for now, keep the nextCursor of every answer and pass it back as since every few minutes (straight away again while hasMore is true). Each entry names a record that was added, changed or deleted since — students, families, vouchers, receipts, registers, applications and the school's structure, only those the key may read and on its campus — and you read the ones you care about with their GET endpoint. Changes are kept 30 days; a cursor older than that gets 409 cursor_expired: read the lists again, then poll from a new cursor.
A polling loop
Save the cursor only after you've handled a page, so a crash never skips a change.
curl "https://api.school360.cloud/v1/changes" -H "Authorization: Bearer $SCHOOL360_KEY"
# → { "data": [], "nextCursor": "eyJzIjo4MTIzNH0", "hasMore": false } keep the cursor
curl "https://api.school360.cloud/v1/changes?since=eyJzIjo4MTIzNH0" -H "Authorization: Bearer $SCHOOL360_KEY"
# → { "data": [{ "entity": "students", "id": 412, "op": "changed", "at": "2026-10-08T07:41:12.000Z" }, …],
# "nextCursor": "eyJzIjo4MTI5MH0", "hasMore": false }import fs from "node:fs/promises"
const headers = { Authorization: `Bearer ${process.env.SCHOOL360_KEY}` }
const CURSOR_FILE = "./school360.cursor"
// Run every few minutes (cron, a scheduler, setInterval)
async function poll() {
let since = await fs.readFile(CURSOR_FILE, "utf8").catch(() => "")
let more = true
while (more) {
const res = await fetch(`https://api.school360.cloud/v1/changes${since ? `?since=${since}` : ""}`, { headers })
const page = await res.json()
if (res.status === 409) throw new Error("cursor_expired: read the lists again, then poll from a new cursor")
if (!res.ok) throw new Error(`${res.status} ${page.error.code}`)
for (const c of page.data) {
if (c.entity === "students" && c.op === "changed") await syncStudent(c.id) // GET /students/{id}
if (c.entity === "students" && c.op === "deleted") await removeStudent(c.id)
}
since = page.nextCursor
await fs.writeFile(CURSOR_FILE, since) // only after the page is handled
more = page.hasMore
}
}import os, pathlib
import requests
api = requests.Session()
api.headers["Authorization"] = f"Bearer {os.environ['SCHOOL360_KEY']}"
cursor_file = pathlib.Path("school360.cursor")
def poll():
"""Run every few minutes (cron, a scheduler)."""
since = cursor_file.read_text() if cursor_file.exists() else ""
while True:
res = api.get("https://api.school360.cloud/v1/changes", params={"since": since} if since else {}, timeout=30)
if res.status_code == 409:
raise RuntimeError("cursor_expired: read the lists again, then poll from a new cursor")
res.raise_for_status()
page = res.json()
for c in page["data"]:
if c["entity"] == "receipts" and c["op"] == "changed":
sync_receipt(c["id"]) # GET /receipts/{id}
since = page["nextCursor"]
cursor_file.write_text(since) # only after the page is handled
if not page["hasMore"]:
breakWhat's in the feed
students,families(with their keys' permissions)vouchers,receipts(fees:read)registers: attendance registers (attendance:read; read them with GET /attendance)applications(admissions:read)campuses,years,classes,sections(every key)
Staff, exams, notices and the gate log aren't in the feed: use their lists (updatedSince on staff) or webhooks.
Expired cursors
Changes are kept 30 days. A cursor older than that answers 409 cursor_expired: read the lists you keep again, then call GET /changes without since for a fresh cursor.