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

  1. The school's owner opens Settings → API & integrations, makes a key, and chooses what it may read: students, staff, attendance, results or fees.
  2. The key starts with sk_ and is shown once. Keep it on your server only: never in a web page or mobile app.
  3. Send it in the Authorization header. 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.

EndpointKey must includeReturns
GET /schoolanyYour school's name and currency.
GET /termsanyAcademic years with their terms or semesters.
GET /classesstudentsClasses and their sections (arms).
GET /studentsstudentsStudents with their class and parents (name, relationship, phone, email).Filters: classId, status (active, graduated…), updatedSince (ISO time)
GET /students/{id}studentsOne student.
GET /staffstaffStaff with job title and department. Never pay, bank or next-of-kin details.Filters: status
GET /attendanceattendanceEach class register for a day, with every student's mark.Filters: date (required, YYYY-MM-DD), classId
GET /resultsresultsPublished report cards for a term, exactly as parents see them.Filters: termId (required), studentId
GET /invoicesfeesBills: total, paid, balance, status and due date.Filters: termId, studentId
GET /paymentsfeesReceipts: 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; use updatedSince).
  • 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);