For app vendors

Vendor integration guide

Vendor integration guide

For product teams connecting an app to EduSpaze. Everything here is available from your Startup portal under Integration & API. Version 2026-09-06 (returned in the X-EduSpaze-Api-Version header). Questions: your EduSpaze partner manager.

1. What you integrate, and why

Schools connect pupils to your app through EduSpaze. Two things flow between us:

  1. Launch — a pupil, or a teacher, clicks "Open" in EduSpaze and arrives in your app with a pseudonymous id. You never receive a name, an email or a date of birth.
  2. Roster — you read which class and groups each of your connected pupils is in, and which pseudonymous teacher teaches those classes, so you can place people correctly.
  3. Telemetry — you tell us when that pupil was active, so the school and (where the school allows it) the parent can see *when* and *how long*. Never *what*: we do not accept scores, content or activity detail, and the API has no field for them.

Integration tiers shown on your listing:

TierRequirement
Tier 1 · Certified SSOSAML2/OIDC or LTI 1.3 with the school, plus telemetry
Tier 2 · ConnectedSigned launch handshake (this guide) plus telemetry
Tier 3 · ListedNo integration; pupils use the EduSpaze app frame and we estimate engagement

Both sides of this integration are also asked to follow the roster mapping protocol: the lifecycle from agreement to exit, what the school owes you, what you owe the school, and what is never shared in either direction.

2. Credentials

Issue both from Integration & API. Each is shown once; rotate if lost.

CredentialFormatUsed for
API keyesk_… (47 chars)Authorization: Bearer on every telemetry call
Launch signing secretels_…Verifying the HMAC on launch parameters

We store only a hash of the API key. Rotating either credential invalidates the previous one immediately.

3. Launch handshake

Set a Launch URL (https only). When a pupil opens your app we redirect the browser to it with these query parameters:

ParameterMeaning
subjectPseudonymous pupil id, stable per school (e.g. RB-4213). Use it as your user key.
tenantEduSpaze school id. Send it back on every telemetry call.
tsUnix milliseconds when the launch was signed
nonceRandom per launch
sigHMAC-SHA256 over the canonical string, hex encoded

Canonical string: the four parameters (not sig) sorted by name, joined as key=value with &:

nonce=<nonce>&subject=<subject>&tenant=<tenant>&ts=<ts>

Verify before trusting subject:

import { createHmac, timingSafeEqual } from "node:crypto";
function verifyLaunch(secret, q) {
  if (Math.abs(Date.now() - Number(q.ts)) > 5 * 60_000) return false; // 5-minute window
  const canonical = ["nonce", "subject", "tenant", "ts"].map((k) => `${k}=${q[k]}`).join("&");
  const expected = createHmac("sha256", secret).update(canonical).digest("hex");
  return expected.length === q.sig.length && timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(q.sig, "hex"));
}

Reject replays by remembering nonces for five minutes. If you leave the Launch URL empty, pupils open your listing inside the EduSpaze app frame instead and we measure engagement ourselves (estimated tier).

3.1 v2 — class information on the launch (opt in)

Switch on Send class information on launch under Integration & API and the launch carries who the person is to their school and where they belong:

ParameterSent forMeaning
veveryone2. Absent means v1.
roleeveryonestudent, teacher, staff or school_admin
classeseveryonecomma-joined class ids — for a pupil the class they are in, for a teacher every class they teach
class, className, yearpupilsthe pupil's single current class, for convenience
groupspupilscomma-joined group ids (reading group, CCA, intervention)

Every id is the same value GET /roster returns, so the two agree.

Read this before switching it on. In v2 the signature covers every parameter present, not the original four. The verifier in §3 hard-codes ["nonce","subject","tenant","ts"] — that verifier will reject a v2 launch. Sign whatever you receive instead:

const canonical = Object.keys(q).filter((k) => k !== "sig").sort().map((k) => `${k}=${q[k]}`).join("&");

That expression is correct for v1 and v2 alike, so make the change first and switch v2 on after. This is why v2 is opt-in per vendor and why nothing changes for you until you ask.

3.2 Teachers

A teacher opening your app from their school portal arrives through the same handshake, with role=teacher and the classes they teach. Their subject is a staff code (STF-0003), pseudonymous and stable within that school, in the same namespace as pupil subjects — so one subject column holds both. Teachers appear in the roster too, so you can build their view before they ever arrive.

4. Telemetry API

Base URL: https://<eduspaze-host>/api/vendor/v1. JSON request and response bodies. Header on every call: Authorization: Bearer esk_….

GET /me

Credential check. Returns your vendor id, product name, tier, launch settings — including whether launch v2 is on (launchRosterClaims) — and tenants: the schools where you have live connections, with their ids. Those are the only tenantId values the other endpoints accept, so this is where a sync starts.

{ "vendorId": "…", "productName": "Practicle", "integrationTier": "CONNECTED",
  "launchUrl": "https://…", "launchSigningConfigured": true, "launchRosterClaims": false,
  "tenants": [{ "id": "sch_…", "name": "Riverbank School" }] }

POST /heartbeat

Send while a pupil is active in your app — once per pupil per minute is enough — and once more with "kind": "end" when they leave.

{ "tenantId": "<tenant from launch>", "subject": "<subject from launch>", "kind": "ping", "at": "2026-09-02T08:15:00Z" }
FieldRequiredNotes
tenantIdyesthe tenant launch parameter
subjectyesthe subject launch parameter
kindnoping (default) or end
atnoISO-8601; defaults to now; must be within ±15 minutes

Response: { ok, sessionId, deduped, recommendedIntervalSeconds }. deduped: true means the ping was within 15 seconds of the previous one and was ignored. Pings are folded into the pupil's session on our side; you do not need to track sessions to use this endpoint.

POST /sessions

Completed sessions from your own records — the highest-confidence source. Batch up to 500.

{ "sessions": [
  { "tenantId": "<tenant>", "subject": "<subject>", "startedAt": "2026-09-01T09:00:00Z", "endedAt": "2026-09-01T09:45:00Z" }
] }

Rules: endedAt after startedAt, at most 12 hours long, not in the future. A session is identified by tenant + you + subject + startedAt; re-sending a batch is safe and is reported as duplicates. Response: { ok, accepted, duplicates, rejected }. rejected counts subjects we could not match to an active connection for you — never a reason to retry.

Errors

StatusMeaning
400Malformed body; issues[] lists the problems
401Missing or invalid key (we never say which)
404Unknown subject for this tenant and vendor

Which endpoint should I use?

  • Need to know which class someone is in? GET /roster (nightly is fine), or opt in to launch v2 to be told as they arrive. Doing both is normal: the launch places them immediately, the roster keeps you right when a school moves somebody between classes.
  • Can you post completed sessions from your logs? Use /sessions (nightly batch is fine).
  • Can you only signal presence in real time? Use /heartbeat.
  • Both is best: heartbeats give the school a same-day picture, sessions make it exact. We merge them; nothing is counted twice.

5. Roster API

GET /roster

GET /api/vendor/v1/roster?tenantId=<tenant>[&updatedSince=<iso>][&cursor=<cursor>][&limit=200]

The pupils connected to you at one school, with their class and group codes, plus the pseudonymous teachers of those classes.

{
  "ok": true,
  "tenantId": "sch_…",
  "pupils": [
    { "subject": "RB-4213", "yearGroup": "Year 5", "className": "5A",
      "classes": [{ "id": "cls_…", "name": "5A", "yearGroup": "Year 5", "academicYear": "2026/27" }],
      "groups": [{ "id": "grp_…", "name": "Reading group B" }],
      "connectedAt": "2026-08-20T…", "updatedAt": "2026-09-01T…" }
  ],
  "staff": [
    { "subject": "STF-0003", "role": "TEACHER", "classes": [{ "id": "cls_…", "name": "5A" }], "updatedAt": "2026-09-01T…" }
  ],
  "nextCursor": null
}
ParameterNotes
tenantIdrequired; the tenant from a launch
updatedSinceISO-8601. Returns pupils whose record, connection, enrolment or group membership changed since. Coarse on purpose — you may be handed a row you already had, never miss one that moved.
cursorfrom a previous nextCursor
limit1–500, default 200

Scope: only pupils with an active connection to you, only in the tenant you name. An unknown tenant and a tenant where you have no pupils answer identically — an empty roster — so this endpoint cannot be used to find out which schools are on the platform.

Sync nightly, or after a launch whose class you do not recognise. Class and group names are teaching labels, not personal data; nobody's name, email or date of birth appears here.

6. What schools and parents see

Minutes per pupil per app, labelled by confidence: *Reported by the app* (your sessions or heartbeats), *Estimated from activity* (our heartbeat when a pupil uses the app frame), or *Launch only*. Parents see a timeline heat map; a launch with no telemetry appears as a single "opened" mark, never as time. Details: Engagement tracking.

7. Data protection

  • The subject id is pseudonymous and per school — pupils (RB-4213) and staff (STF-0003) alike. Do not attempt re-identification.
  • Class and group ids are opaque and per school. Their names are teaching labels; do not treat them as free text about a person.
  • When a school revokes a connection, subject stops being accepted; delete what you hold for it within your agreed retention.
  • When a school anonymises a pupil, the id is retained on our side only as a token; historic telemetry stays time-only.
  • The API stores no free text. Do not send names in any field.

8. Try it against the demo

The demo dataset ships a fixed key for the vendor Practicle so the flow can be exercised:

Authorization: Bearer esk_demo_practicle_telemetry_key_0000000000000

Take a tenantId from GET /me (Riverbank School in the demo) and use subject RB-4213:

curl -s https://<eduspaze-host>/api/vendor/v1/me -H "Authorization: Bearer esk_demo_practicle_telemetry_key_0000000000000"
curl -s -X POST https://<eduspaze-host>/api/vendor/v1/heartbeat \
  -H "Authorization: Bearer esk_demo_practicle_telemetry_key_0000000000000" -H "Content-Type: application/json" \
  -d '{"tenantId":"<riverbank id>","subject":"RB-4213"}'

Then open School → Engagement (as riverbank@school.demo) to see the session appear. Production keys are random, issued from the portal and shown once.

Source: docs/integration/vendor.md in the repository. Last updated with the code it describes.

Vendor integration guide — EduSpaze