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:
- 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.
- 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.
- 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:
| Tier | Requirement |
|---|---|
| Tier 1 · Certified SSO | SAML2/OIDC or LTI 1.3 with the school, plus telemetry |
| Tier 2 · Connected | Signed launch handshake (this guide) plus telemetry |
| Tier 3 · Listed | No 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.
| Credential | Format | Used for |
|---|---|---|
| API key | esk_… (47 chars) | Authorization: Bearer on every telemetry call |
| Launch signing secret | els_… | 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:
| Parameter | Meaning |
|---|---|
subject | Pseudonymous pupil id, stable per school (e.g. RB-4213). Use it as your user key. |
tenant | EduSpaze school id. Send it back on every telemetry call. |
ts | Unix milliseconds when the launch was signed |
nonce | Random per launch |
sig | HMAC-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:
| Parameter | Sent for | Meaning |
|---|---|---|
v | everyone | 2. Absent means v1. |
role | everyone | student, teacher, staff or school_admin |
classes | everyone | comma-joined class ids — for a pupil the class they are in, for a teacher every class they teach |
class, className, year | pupils | the pupil's single current class, for convenience |
groups | pupils | comma-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" }
| Field | Required | Notes |
|---|---|---|
tenantId | yes | the tenant launch parameter |
subject | yes | the subject launch parameter |
kind | no | ping (default) or end |
at | no | ISO-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
| Status | Meaning |
|---|---|
| 400 | Malformed body; issues[] lists the problems |
| 401 | Missing or invalid key (we never say which) |
| 404 | Unknown 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
}
| Parameter | Notes |
|---|---|
tenantId | required; the tenant from a launch |
updatedSince | ISO-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. |
cursor | from a previous nextCursor |
limit | 1–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,
subjectstops 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.
