// 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 https endpoint 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.

every request
Authorization: Bearer kat_sk_xxxxxxxxxxxxxxxx

Scopes

ScopeLets the key…
CLASSES_READList your classes.
PROGRESS_READRead pupil progress. Also required to register webhooks.
RESULTS_READRead assessment results.
ROSTER_WRITEpowerfulCreate and deactivate pupil accounts.
EMBED_MINTpowerfulSign 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.

1. list your classes
curl https://schools.kindleatechie.com/api/v1/classes \
  -H "Authorization: Bearer $KAT_API_KEY"
response
{
  "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.

POST /v1/roster · scope: ROSTER_WRITE
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" }
    ]
  }'
response
{
  "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.

a nightly sync · PHP
<?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.

deactivate pupils missing from the list
{
  "class_id": "cls_abc123",
  "students": [ … ],
  "deactivate_missing": true
}

Reading progress and results

GET /v1/students/{student_id}/progress · scope: PROGRESS_READ
curl https://schools.kindleatechie.com/api/v1/students/STU-0417/progress \
  -H "Authorization: Bearer $KAT_API_KEY"
response
{
  "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
    }
  ]
}
GET /v1/results · scope: RESULTS_READ
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.

POST /v1/webhooks · scope: PROGRESS_READ
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 lesson
  • assessment.graded: a pupil's assessment is marked

Payloads carry no names

POST to your endpoint
{
  "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:

node.js
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

  1. Your server asks us for a launch token for one pupil, using your API key.
  2. Your page drops that token into an iframe URL, after the #.
  3. The frame exchanges it for a session and the pupil is in their lessons.
1. on your server: mint a token
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 }
2. on your page: put it after the #
<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.

functions.php or an mu-plugin · PHP
<?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 of Referer headers, 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

every error
{ "error": "This key does not have the ROSTER_WRITE scope.", "code": "insufficient_scope" }
StatusMeaning
400Malformed JSON or payload.
401Key missing, wrong, or revoked.
403Key is valid but lacks the scope.
404Not found, or not yours. We do not distinguish.
409Refused for safety (e.g. the 20% rule). Nothing changed.
422Understood, but rejected: over seats, unsafe URL, key reuse.
429Rate 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-Key on every roster sync.
  • Webhook signature verified, using the raw body, with the 5-minute timestamp check.
  • deactivate_missing tested 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.