Dalinplug SchoolsAPI documentation

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/v1

End-to-end quick start

  1. Sign in as a school administrator and open Admin → Developer platform.
  2. 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.
  3. Store it as a secret on your server (an environment variable or secrets manager).
  4. Call the API:
Shell
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"
Response
{
  "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>
ScopeAllows
students:readGET /students
announcements:readGET /announcements
analytics:readGET /analytics/summary
roster:readAll 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

  • limit is 1-250 (default 100) and offset is 0-50,000 (default 0). Every list returns meta.total, meta.limit and meta.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 401 with { "error": "unauthorized" } and a WWW-Authenticate: Bearer header.
  • Each key may make 120 requests a minute and 2,000 an hour. Beyond that the API also answers 401 until the window passes, so back off and retry later instead of retrying immediately.
  • A server problem returns 500 with { "error": "query_failed" }; retry with backoff.

Students

GET /api/v1/students?limit=100&offset=0        # scope: students:read

Returns 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:read

status 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
Response shape
{
  "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.

EndpointReturns
GET /api/v1/ims/oneroster/rostering/v1p2/orgsorgs: the school
GET /api/v1/ims/oneroster/rostering/v1p2/usersusers: students; add ?role=teacher or ?role=administrator for staff
GET /api/v1/ims/oneroster/rostering/v1p2/classesclasses
GET /api/v1/ims/oneroster/rostering/v1p2/enrollmentsenrollments linking users to classes
GET /api/v1/ims/oneroster/gradebook/v1p2/lineItemslineItems: assessed subjects within a class
GET /api/v1/ims/oneroster/gradebook/v1p2/resultsresults: 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

EventSent whenPayload fields
admission.submittedNew admission applicationapplication_id
enquiry.receivedNew website enquiryenquiry_id, enquiry_type
student.createdStudent record createdstudent_id
announcement.publishedAnnouncement publishedannouncement_id, audience
result.publishedResult published to familiespublication_id, term_period_id
invoice.issuedInvoice issuedinvoice_id, amount_minor, currency
payment.succeededPayment recordedpayment_id, invoice_id, amount_minor, currency, method
attendance.absentStudent marked absentstudent_id, attendance_date
assignment.submittedAssignment submittedassignment_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

Request
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 2xx within 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-Id you 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.

Node.js (Express)
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);
});
Python (Flask)
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 "", 200

Go-live checklist

  • One key per integration, with the smallest set of scopes, stored server-side only.
  • You page through lists with limit/offset and slow down when you see 401 after a burst.
  • Your webhook endpoint verifies the signature and timestamp, de-duplicates on the event id and answers 2xx quickly.
  • You treat money as integer minor units.

Ready? Open Admin → Developer platform in your school, or contact us.