// api v1
Connect KAT to your school's own systems.
Sync your roster from the system where you already keep your pupil records. Pull progress and results into your own reports. Sign pupils straight into their lessons from your portal, with no second password. Get a webhook the moment a pupil finishes a lesson.
Everything below is scoped to your school. There is no school_id parameter anywhere in this API. You cannot reach another school, even by accident.
Before you start
This is a small REST API. If you can make an HTTPS request from your server, you can integrate. You will need:
- A server. Your API key must never appear in a web page. See API keys.
- A stable pupil id. Whatever your school system already calls a pupil, whether that is an admission number or a register number. We call it
student_id. It never leaves your school. - For webhooks only: a public
httpsendpoint we can POST to. - For the lesson window only: the exact address of the page it will sit on, e.g.
https://portal.yourschool.edu.ng.
Three things to know
student_id
Your id for a pupil, not ours. You send it when you upload your roster, and every endpoint after that speaks it back to you. It is meaningless outside your school.
We never accept a name or an email as a lookup key. An endpoint that took an email would let anyone guess their way through your roll.
class_id
Ours. Get it from GET /v1/classes. Classes are created by your school admin in the KAT dashboard.
scopes
What a key is allowed to do. Give each system its own key with only what it needs, so revoking one does not take down the others.
API keys
Your school admin creates keys in the KAT dashboard, under Connect your school's own systems. The secret is shown once. We store only a hash of it and genuinely cannot show it again.
Authorization: Bearer kat_sk_xxxxxxxxxxxxxxxxScopes
| Scope | Lets the key… |
|---|---|
CLASSES_READ | List your classes. |
PROGRESS_READ | Read pupil progress. Also required to register webhooks. |
RESULTS_READ | Read assessment results. |
ROSTER_WRITEpowerful | Create and deactivate pupil accounts. |
EMBED_MINTpowerful | Sign in as any pupil at your school. |
Missing a scope gives you 403 insufficient_scope, naming the one you need.
Rotating a key
Create the new key → point your system at it → revoke the old one. Both work until you revoke, so there is no window where your integration is down.
Revocation takes effect immediately. Revoked keys are kept, not deleted. If a key ever leaks, the record of what it did is exactly what you will want to read.
Quickstart
Your first request. Everything else is a variation on this.
curl https://schools.kindleatechie.com/api/v1/classes \
-H "Authorization: Bearer $KAT_API_KEY"{
"data": [
{
"id": "cls_abc123",
"name": "Primary 5A",
"nerdc_level": "PRIMARY_4_6",
"session": "2026/2027",
"teacher": "Ngozi Okafor",
"student_count": 32
}
],
"has_more": false,
"next_cursor": null
}That id is the class_id you will use everywhere below.
Roster sync
Send us a class list. New pupils are created; pupils already on the roster are skipped. Run it nightly, run it twice. It is safe.
curl -X POST https://schools.kindleatechie.com/api/v1/roster \
-H "Authorization: Bearer $KAT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 2026-09-01-primary5a" \
-d '{
"class_id": "cls_abc123",
"students": [
{ "student_id": "STU-0417", "name": "Chidi Okafor", "guardian_email": "parent@example.com" },
{ "student_id": "STU-0418", "name": "Ada Balogun" }
]
}'{
"created": 2,
"reactivated": 0,
"skipped": 0,
"deactivated": 0,
"errors": [],
"seats": { "used": 32, "limit": 40 }
}Using WordPress?
The same call from your server. Run it on a daily wp_schedule_event, or from your enrolment form. Store each student_id back on the WordPress user so the lesson window (above) can find that pupil later.
<?php
$res = wp_remote_post( 'https://schools.kindleatechie.com/api/v1/roster', array(
'headers' => array(
'Authorization' => 'Bearer ' . KAT_API_KEY,
'Content-Type' => 'application/json',
'Idempotency-Key' => wp_date( 'Y-m-d' ) . '-primary5a', // stable per sync. a retry replays.
),
'body' => wp_json_encode( array(
'class_id' => 'cls_abc123',
'students' => array(
array( 'student_id' => 'STU-0417', 'name' => 'Chidi Okafor', 'guardian_email' => 'parent@example.com' ),
array( 'student_id' => 'STU-0418', 'name' => 'Ada Balogun' ),
),
) ),
'timeout' => 20,
) );
$body = is_wp_error( $res ) ? null : json_decode( wp_remote_retrieve_body( $res ), true );
// $body['created'], $body['seats']['used'] … then remember the mapping for the lesson window:
// update_user_meta( $wp_user_id, 'kat_external_ref', 'STU-0417' );Always send an Idempotency-Key
A sync of 400 pupils over a flaky line will get retried. With an Idempotency-Key, the retry replays the original response instead of creating every child a second time. Use anything stable and unique per sync. A date plus the class works well.
The same key with a different body is a 422. That is deliberate: silently replaying an unrelated response would be worse than either honest answer.
Removing pupils
We never delete a pupil. Deleting one would take their progress, their certificates and their history with them. Pupils are deactivated, and come back the moment they reappear in a sync.
{
"class_id": "cls_abc123",
"students": [ … ],
"deactivate_missing": true
}Reading progress and results
curl https://schools.kindleatechie.com/api/v1/students/STU-0417/progress \
-H "Authorization: Bearer $KAT_API_KEY"{
"student_id": "STU-0417",
"name": "Chidi Okafor",
"status": "ACTIVE",
"class_id": "cls_abc123",
"lessons_total": 36,
"lessons_completed": 12,
"percent_complete": 33,
"units": [
{
"unit_id": "mod_…",
"title": "Term 1: Introduction to Computers",
"strand": "DIGLIT",
"lessons_total": 6,
"lessons_completed": 6,
"percent_complete": 100
}
]
}curl "https://schools.kindleatechie.com/api/v1/results?class_id=cls_abc123" \
-H "Authorization: Bearer $KAT_API_KEY"Both endpoints paginate: ?limit= (default 50, max 200) and ?cursor=. Follow next_cursor until has_more is false.
Webhooks
Rather than polling, let us tell you.
curl -X POST https://schools.kindleatechie.com/api/v1/webhooks \
-H "Authorization: Bearer $KAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "url": "https://portal.yourschool.edu.ng/hooks/kat" }'Returns a secret, shown once. Your URL must be https and publicly reachable. Private and internal addresses are rejected.
Events
lesson.completed: a pupil finishes a lessonassessment.graded: a pupil's assessment is marked
Payloads carry no names
{
"id": "whd_…",
"type": "lesson.completed",
"createdAt": "2026-09-12T09:14:22.000Z",
"data": {
"student_id": "STU-0417",
"class_id": "cls_abc123",
"lesson_id": "les_…",
"completed_at": "2026-09-12T09:14:22.000Z"
}
}Refs only, with no names and no emails. Call the API back if you need detail. This keeps children's names out of your webhook logs and your error tracker, which is where data actually leaks from.
Verify every delivery
We sign with Standard Webhooks, so an off-the-shelf library will work. Or do it by hand:
const crypto = require("crypto");
function verify(req, secret) {
const id = req.headers["webhook-id"];
const ts = req.headers["webhook-timestamp"];
const sig = req.headers["webhook-signature"];
// Reject anything older than 5 minutes, or a captured delivery
// can be replayed against you forever.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = "v1," + crypto
.createHmac("sha256", secret.replace(/^whsec_/, ""))
.update(`${id}.${ts}.${req.rawBody}`) // the RAW body, before JSON.parse
.digest("base64");
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
}Retries
A non-2xx or a timeout is retried with backoff (1 minute → 12 hours) for up to 10 attempts. After 10 consecutive failures the endpoint is disabled and your admin is told, rather than us hammering a dead URL while live events queue up behind it.
Return 2xx quickly and do your work afterwards.
Magic-link SSO: lessons inside your own site
Put a pupil's lessons on your portal. They click through and land already signed in. No second password to remember, and none to reset.
How it works
- Your server asks us for a launch token for one pupil, using your API key.
- Your page drops that token into an iframe URL, after the
#. - The frame exchanges it for a session and the pupil is in their lessons.
curl -X POST https://schools.kindleatechie.com/api/school/embed/token \
-H "Authorization: Bearer $KAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "ref": "STU-0417" }'
# → { "token": "eyJhbGciOi…", "expiresIn": 60 }<iframe
src="https://schools.kindleatechie.com/embed/your-school#t=THE_TOKEN"
style="width:100%;height:720px;border:0"
referrerpolicy="no-referrer"
></iframe>Using WordPress?
The same two steps, as one shortcode. The key lives in wp-config.php and is read only on the server. You mint a token for the pupil who is signed in right now, from your own user mapping, never from a query string.
<?php
// wp-config.php: define( 'KAT_API_KEY', 'kat_sk_live_…' ); // server-only. never in a page.
function kat_launch_iframe( $student_id ) {
$res = wp_remote_post( 'https://schools.kindleatechie.com/api/school/embed/token', array(
'headers' => array(
'Authorization' => 'Bearer ' . KAT_API_KEY,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( array( 'ref' => $student_id ) ),
'timeout' => 15,
) );
if ( is_wp_error( $res ) || 200 !== wp_remote_retrieve_response_code( $res ) ) {
return '<p>Could not open the classroom. Please try again.</p>';
}
$token = json_decode( wp_remote_retrieve_body( $res ), true )['token'] ?? '';
// Token goes after the #, so it never reaches a server log or a Referer header.
$src = 'https://schools.kindleatechie.com/embed/your-school#t=' . rawurlencode( $token );
return sprintf(
'<iframe src="%s" style="width:100%%;height:720px;border:0" referrerpolicy="no-referrer"></iframe>',
esc_url( $src )
);
}
// [kat_classroom] on a members-only page. Only ever the ref of the current logged-in pupil.
add_shortcode( 'kat_classroom', function () {
if ( ! is_user_logged_in() ) { return 'Please sign in.'; }
$ref = get_user_meta( get_current_user_id(), 'kat_external_ref', true );
return $ref ? kat_launch_iframe( $ref ) : 'Your account is not linked to KAT yet.';
} );Do not cache a page that carries the shortcode. The token is minted at render and lives 60 seconds, so the page must be built fresh on each visit.
The token
- Lives 60 seconds. Mint it when the page is served, not in advance.
- Works once. Reusing it fails. Mint a fresh one per page load.
- Goes after the
#, never after a?. A URL fragment is never sent to a server, so the token stays out of access logs, out ofRefererheaders, and out of any analytics on your page. A query string would leak it into all three.
Requirement: register your page's address
Your school admin must add the exact origin the iframe will sit on (e.g. https://portal.yourschool.edu.ng) in the KAT dashboard. Until they do, the frame will not load anywhere.
We do not accept wildcards. https://*.yourschool.edu.ng would let any subdomain you have forgotten about, an old WordPress, a student club page, frame a signed-in child's session. List the two or three real addresses instead.
Safari
Safari and some privacy settings block the cookie an embedded frame needs. When that happens the frame does not break, it shows a single “Open my lessons” button that opens the same lesson in a tab, signed in exactly the same way. Nothing to configure; it just needs to be allowed to open a tab.
Errors, limits, versioning
{ "error": "This key does not have the ROSTER_WRITE scope.", "code": "insufficient_scope" }| Status | Meaning |
|---|---|
| 400 | Malformed JSON or payload. |
| 401 | Key missing, wrong, or revoked. |
| 403 | Key is valid but lacks the scope. |
| 404 | Not found, or not yours. We do not distinguish. |
| 409 | Refused for safety (e.g. the 20% rule). Nothing changed. |
| 422 | Understood, but rejected: over seats, unsafe URL, key reuse. |
| 429 | Rate limited. Wait for Retry-After. |
Rate limit
600 requests per minute, per key. Over it you get a 429 with a Retry-After header in seconds. Honour it rather than guessing.
Versioning
v1 is stable. Breaking changes ship as v2; v1 keeps working. We may add fields to a v1 response, so ignore fields you do not recognise rather than failing on them.
Go-live checklist
- API key stored as a server-side environment variable, not in the codebase, not in the page.
- A separate key per system, each with only the scopes it needs.
Idempotency-Keyon every roster sync.- Webhook signature verified, using the raw body, with the 5-minute timestamp check.
deactivate_missingtested against a real export before you trust it on a live class.- Your iframe origin registered in the dashboard.
- A plan for rotating the key. You will need to one day, and it is much easier when it is not an emergency.
Stuck?
Email hello@kindleatechie.com with your school name and the request you are making. Never send us your API key. We will never ask for it, and anyone who does is not us.