For developers
API guide
Connect a school's other systems (accounting, its website, a government portal) to Qyskul. The API reads a school's data; webhooks tell your system when something happens. Available on the Enterprise plan.
Getting started
- The school's owner opens Settings → API & integrations, makes a key, and chooses what it may read: students, staff, attendance, results or fees.
- The key starts with
sk_and is shown once. Keep it on your server only: never in a web page or mobile app. - Send it in the
Authorizationheader. Every call reads; nothing can be changed through the API.
curl https://qyskul.com/api/v1/students?limit=2 \
-H "Authorization: Bearer sk_your_key"{
"data": [
{
"id": "8f1c…",
"admissionNumber": "RO/2026/0001",
"firstName": "Chidinma",
"middleName": null,
"lastName": "Okafor",
"gender": "female",
"dateOfBirth": "2015-03-02",
"status": "active",
"admittedOn": "2026-09-07",
"class": { "id": "c41…", "name": "JSS 1 A", "sectionId": "s09…" },
"guardians": [
{ "name": "Ngozi Okafor", "relationship": "Mother",
"phone": "0803 123 4567", "email": null, "primary": true }
],
"updatedAt": "2026-10-24T09:12:44.000Z"
}
],
"nextCursor": "8f1c…"
}Money is in the smallest unit (kobo for naira): 4500000 is ₦45,000. Dates are YYYY-MM-DD; times are ISO 8601 in UTC.
Paging
Lists return up to limit items (default 50, at most 200). If there are more, nextCursor is set: pass it as cursor to get the next page. When it's null, you have everything.
GET /students?limit=200
GET /students?limit=200&cursor=8f1c…To stay in step, ask for /students?updatedSince=2026-10-24T00:00:00Z on a schedule, plus the student.created webhook.
Endpoints
Base address: https://qyskul.com/api/v1. The key's school is implied: you never pass a school id.
| Endpoint | Key must include | Returns |
|---|---|---|
| GET /school | any | Your school's name and currency. |
| GET /terms | any | Academic years with their terms or semesters. |
| GET /classes | students | Classes and their sections (arms). |
| GET /students | students | Students with their class and parents (name, relationship, phone, email).Filters: classId, status (active, graduated…), updatedSince (ISO time) |
| GET /students/{id} | students | One student. |
| GET /staff | staff | Staff with job title and department. Never pay, bank or next-of-kin details.Filters: status |
| GET /attendance | attendance | Each class register for a day, with every student's mark.Filters: date (required, YYYY-MM-DD), classId |
| GET /results | results | Published report cards for a term, exactly as parents see them.Filters: termId (required), studentId |
| GET /invoices | fees | Bills: total, paid, balance, status and due date.Filters: termId, studentId |
| GET /payments | fees | Receipts: amount, method (cash, transfer, online, wallet…), date, invoice. Voided ones are marked.Filters: from, to (YYYY-MM-DD), studentId |
Errors and limits
Errors are JSON with a code and a readable message:
- 401
NO_API_KEY/BAD_API_KEY: no key, or it's wrong or revoked. - 403
SCOPE_MISSING: the key can't read that area.API_NOT_AVAILABLE: the school isn't on a plan with API access. - 400: a filter is missing or malformed (e.g. attendance without a date).
- 429
TOO_MANY_REQUESTS: more than 120 calls a minute with one key. Wait a moment and try again.
Webhooks
In Settings → API & integrations, add your server's https address and choose the events. We send a POST with a JSON body the moment it happens:
payment.received: a payment recorded at the office, paid online or paid from a wallet (same shape as/payments).student.created: a student added or enrolled from admissions (imports from Excel don't send one each; useupdatedSince).results.published: report cards published for a class: term, class and student ids. Read them from/results.
POST https://yourserver.com/school-webhook
X-Webhook-Event: payment.received
X-Webhook-Id: 3b7e…
X-Webhook-Signature: t=1730000000,v1=5f0c…
{
"id": "3b7e…",
"event": "payment.received",
"createdAt": "2026-10-26T10:15:00.000Z",
"data": {
"id": "a12…", "receiptNumber": "RCT-0042", "amount": 4500000,
"method": "online", "paidOn": "2026-10-26", "studentId": "8f1c…",
"invoiceId": "e77…", "invoiceNumber": "INV-0019", "voided": false
}
}Answer with any 2xx within 10 seconds. If you don't, we try again after 10 seconds and after a minute. Each delivery has an id: if you see it twice, ignore the second.
Checking the signature
Every request is signed with the webhook's secret (shown once when it was added). Compute an HMAC-SHA256 of t + "." + raw body and compare it with v1. Reject it if it doesn't match or if t is more than 5 minutes old.
// Node.js (Express): use the raw body, not parsed JSON
const crypto = require("crypto")
app.post("/school-webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Webhook-Signature") || ""
const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")))
const expected = crypto
.createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${t}.${req.body}`)
.digest("hex")
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300
if (!fresh || !v1 || !crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected)))
return res.sendStatus(400)
const event = JSON.parse(req.body)
// … handle event.event / event.data
res.sendStatus(200)
})<?php // PHP
$body = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', $sig['t'] . '.' . $body, getenv('WEBHOOK_SECRET'));
if (abs(time() - (int) $sig['t']) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400); exit;
}
$event = json_decode($body, true);
http_response_code(200);