Connect other systems to your school
Overview
Each school gets its own read-only REST API. Create an API key, choose what it may read, and call the API from your own server or from a learning platform that understands OneRoster. You can also have the school push signed webhooks to your server when things happen, such as an admission, a published result or a payment.
Every request is limited to the school that owns the key. The API is available to schools on a plan that includes the API module, and keys and webhooks are managed by school administrators in the admin area under Developer platform.
The base URL is your school's own address:
https://<your-school-domain>/api/v1End-to-end quick start
- Sign in as a school administrator and open Admin → Developer platform.
- Under Create API key give it a name, tick the scopes you need and optionally set an expiry date. Copy the key straight away; it is shown once.
- Store it as a secret on your server (an environment variable or secrets manager).
- Call the API:
export SCHOOL_API="https://<your-school-domain>/api/v1"
export SCHOOL_API_KEY="sch_<your-key>" # server-side only
curl "$SCHOOL_API/students?limit=20&offset=0" \
-H "Authorization: Bearer $SCHOOL_API_KEY" \
-H "Accept: application/json"{
"data": [
{
"id": "00000000-0000-0000-0000-000000000000",
"admissionNumber": "S-1001",
"firstName": "Aisha",
"lastName": "Musa",
"otherNames": null,
"dateOfBirth": "2014-05-11T00:00:00.000Z",
"gender": "female",
"active": true,
"admittedOn": "2024-09-01T00:00:00.000Z",
"createdAt": "2024-09-03T09:00:00.000Z"
}
],
"meta": { "total": 1, "limit": 20, "offset": 0 }
}5. Add a webhook (optional) to be told about changes instead of polling. See Webhooks.
Authentication
Authorization: Bearer sch_<your-key>| Scope | Allows |
|---|---|
| students:read | GET /students |
| announcements:read | GET /announcements |
| analytics:read | GET /analytics/summary |
| roster:read | All OneRoster 1.2 endpoints |
- Give each integration its own key with the fewest scopes it needs, so you can revoke one without affecting the others.
- Keys can be rotated or revoked at any time from the Developer platform page; a revoked or expired key stops working immediately.
- Keep keys on your server. Never embed one in a web page, mobile app or source repository. If a key may have leaked, revoke it and create a new one.
Pagination, errors & limits
limitis 1-250 (default 100) andoffsetis 0-50,000 (default 0). Every list returnsmeta.total,meta.limitandmeta.offset.- Field names in responses are camelCase and dates are ISO 8601 timestamps. Responses are never cached.
- A missing, invalid, expired, revoked or out-of-scope key returns
401with{ "error": "unauthorized" }and aWWW-Authenticate: Bearerheader. - Each key may make 120 requests a minute and 2,000 an hour. Beyond that the API also answers
401until the window passes, so back off and retry later instead of retrying immediately. - A server problem returns
500with{ "error": "query_failed" }; retry with backoff.
Students
GET /api/v1/students?limit=100&offset=0 # scope: students:readReturns data, an array of students (newest first) with id, admissionNumber, firstName, lastName, otherNames, dateOfBirth, gender, active, admittedOn and createdAt, plus meta.
Announcements
GET /api/v1/announcements?status=published&limit=50 # scope: announcements:readstatus is optional: draft, scheduled, published or archived; any other value returns 400. Each item has id, title, slug, excerpt, content, category, status, isPinned, publishedAt, expiresAt, coverImagePath, attachmentPath, createdAt and updatedAt.
Analytics summary
GET /api/v1/analytics/summary # scope: analytics:read{
"tenant": { "id": "…", "name": "Example School", "slug": "example", "status": "active",
"currency": "NGN", "locale": "en", "timezone": "Africa/Lagos" },
"usage": [ { "usageDate": "2026-10-19T00:00:00.000Z", "students": 412, "activeStaff": 38, "announcements": 17 } ],
"operational": { "pendingAdmissions": 6, "overdueInvoices": 14, "failedNotifications": 0 }
}usage holds up to the last 30 daily snapshots, newest first.
OneRoster 1.2 export
For learning platforms and student information systems that speak the OneRoster standard. All endpoints need roster:read, are read-only, and accept limit and offset.
| Endpoint | Returns |
|---|---|
| GET /api/v1/ims/oneroster/rostering/v1p2/orgs | orgs: the school |
| GET /api/v1/ims/oneroster/rostering/v1p2/users | users: students; add ?role=teacher or ?role=administrator for staff |
| GET /api/v1/ims/oneroster/rostering/v1p2/classes | classes |
| GET /api/v1/ims/oneroster/rostering/v1p2/enrollments | enrollments linking users to classes |
| GET /api/v1/ims/oneroster/gradebook/v1p2/lineItems | lineItems: assessed subjects within a class |
| GET /api/v1/ims/oneroster/gradebook/v1p2/results | results: scores against line items |
Each response is an object whose first key is the collection above, plus meta (for example { "users": [ … ], "meta": { "total": 412, "limit": 100, "offset": 0 } }). Records use the standard OneRoster fields such as sourcedId, status, givenName and familyName.
Webhooks
Create an outbound webhook under Developer platform → Create outbound webhook: give it a name, a public https:// URL and the events to send. The signing secret is shown once when you create it (or rotate it); store it on your server.
Events
| Event | Sent when | Payload fields |
|---|---|---|
| admission.submitted | New admission application | application_id |
| enquiry.received | New website enquiry | enquiry_id, enquiry_type |
| student.created | Student record created | student_id |
| announcement.published | Announcement published | announcement_id, audience |
| result.published | Result published to families | publication_id, term_period_id |
| invoice.issued | Invoice issued | invoice_id, amount_minor, currency |
| payment.succeeded | Payment recorded | payment_id, invoice_id, amount_minor, currency, method |
| attendance.absent | Student marked absent | student_id, attendance_date |
| assignment.submitted | Assignment submitted | assignment_id, submission_id |
Payloads carry identifiers, counts and amounts only (amount_minor is in the currency's minor unit). They never include names, contact details, grades, health or safeguarding information or free text, so look the record up through the API when you need more.
What your server receives
POST /hooks/school HTTP/1.1
Content-Type: application/json
User-Agent: School-Portal-Webhook/1.0
X-School-Event: payment.succeeded
X-School-Event-Id: 3f0c7a52-0000-0000-0000-000000000000
X-School-Timestamp: 1760952600
X-School-Signature: sha256=<lowercase hex HMAC-SHA256>
{
"id": "3f0c7a52-0000-0000-0000-000000000000",
"type": "payment.succeeded",
"created_at": "2026-10-20T09:30:00.000Z",
"data": { "payment_id": "…", "invoice_id": "…", "amount_minor": 1500000, "currency": "NGN", "method": "card" }
}- Respond with any
2xxwithin 10 seconds to acknowledge. Anything else, or a timeout, counts as a failure. - Failures are retried with growing delays (1, 2, 4, 8 ... minutes, at most an hour) up to 5 attempts, then the delivery is dead-lettered.
- Delivery is at-least-once: ignore an
X-School-Event-Idyou have already processed. - Only public HTTPS destinations are allowed and redirects are not followed. A webhook whose destination becomes unsafe is switched off; re-enable it from the Developer platform page once fixed.
Verifying signatures
The signature is HMAC-SHA256, keyed with your signing secret, over <timestamp>.<exact raw body> (note the dot). Verify before acting, reject stale timestamps (more than five minutes old) and compare in constant time.
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.SCHOOL_WEBHOOK_SECRET; // your own config, never hard-coded
const app = express();
app.post("/hooks/school", express.raw({ type: "application/json" }), (req, res) => {
const timestamp = req.get("X-School-Timestamp") ?? "";
const received = (req.get("X-School-Signature") ?? "").replace(/^sha256=/, "");
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(401);
const expected = crypto.createHmac("sha256", SECRET)
.update(`${timestamp}.`).update(req.body).digest("hex");
const ok = received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// skip if event.id was already processed, then handle event.type / event.data
res.sendStatus(200);
});import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["SCHOOL_WEBHOOK_SECRET"].encode()
app = Flask(__name__)
@app.post("/hooks/school")
def school():
timestamp = request.headers.get("X-School-Timestamp", "")
received = request.headers.get("X-School-Signature", "").removeprefix("sha256=")
if abs(time.time() - float(timestamp or 0)) > 300:
abort(401)
signed = timestamp.encode() + b"." + request.get_data()
expected = hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
if not hmac.compare_digest(received, expected):
abort(401)
return "", 200Go-live checklist
- One key per integration, with the smallest set of scopes, stored server-side only.
- You page through lists with
limit/offsetand slow down when you see401after a burst. - Your webhook endpoint verifies the signature and timestamp, de-duplicates on the event id and answers
2xxquickly. - You treat money as integer minor units.
Ready? Open Admin → Developer platform in your school, or contact us.