People and units
Everything the console's Staff & teams and Learners pages do, the API does too, with the same rules. Every operation here needs people:read or people:write. On keys made before the 2026-10-01 contract, a full-access key holds them only when they are added to it by name. See the People reference for every field.
The pieces#
- A unit (
unit_…) is a class, a team, a group, a department, a region, a school or a club. Units form a tree: each one sits under another unit (parentId) or at the top level. - A person (
usr_…) is anyone in your organization: staff, a learner, a parent. A person has an email, or signs in with a login card (a username and a PIN) when they have none, or is known to you only by your own id (externalId). - A membership (
mbr_…) is a person's place: one role, at one unit or at the organization level. A person can hold several places: a team leader in one unit and a learner in another, for example.
Roles#
| Role | Held at | What it does |
|---|---|---|
OWNER | The organization level | Runs the account: the plan, billing, other owners. Never given, changed, switched off or removed through the API. |
ADMIN | The organization level | Everything an owner does except the account itself. |
AVATAR_BUILDER | The organization level | Builds avatars and portals. |
WORKSPACE_ADMIN | The organization level, for one subject (workspaceId) | Runs one subject. |
LEADER | A unit | A teacher, a manager or a coach: sees and sets work for the people in their units. |
LEARNER | A unit, or the organization level | Practices, learns and is assessed. |
OBSERVER | A unit, or the organization level | Sees results without leading. |
ADMIN, AVATAR_BUILDER and WORKSPACE_ADMIN are admin-level places. Only a full-access key gives, changes, switches off or removes one, or switches off the account of someone who holds one. A key with people:write alone manages learners, leaders and observers (403 forbidden otherwise). Someone holding a staff place (anything but a learner's or an observer's) counts as a staff member on your plan; a new one is refused with 403 upgrade_required when the plan has no room for another.
Units#
POST /v1/units adds one, PATCH /v1/units/{id} renames it, moves it or switches it off, and GET /v1/units lists the tree with each unit's counts. A second unit with the same name at the same level is allowed and flagged (duplicateName).
Switching a unit off (disabled: true) switches off everything below it as well. Nothing is deleted, and it grants nothing until it is switched back on. That is usually what you want when a class ends.
Deleting a unit (DELETE /v1/units/{id}) is for good:
- its sub-units move up to its parent (or the top level), with everything below them;
- the portals, subjects and assignments attached to it move to the same place;
- the places held on it are removed, while the people's accounts and their other places stay;
- sessions, results and reports are never deleted.
GET /v1/units/{id}/deletion-preview shows what deleting it would do, in numbers, without changing anything. A unit holding an owner's place is never deleted through the API, and one holding an admin-level place only with a full-access key. The answer and the unit.deleted event say what moved; each person who had a place on it gets a member.removed event.
Join codes#
Learners can join a unit by typing its code. POST /v1/units/{id}/join-code makes a code, or replaces it: the old code stops working at once. DELETE /v1/units/{id}/join-code removes it. People who already joined keep their places.
Making a code allows joining by code. Where your organization has switched joining by code off for that unit, the switch is turned on for the unit (a setting.changed event says so). Where someone has locked the switch above the unit, both calls are refused with 409 conflict (details.reason: "join_by_code_off"). The unit.updated event says a code was made or removed (joinCode: "rotated" or "removed"), never the code itself.
Adding people#
POST /v1/people adds one person and gives them a place. Send your own id as externalId to keep your system in step: a repeat with the same id returns the same person. POST /v1/people/{id}/memberships gives someone another place. PATCH /v1/memberships/{id} changes a place, and DELETE /v1/memberships/{id} removes it (the account stays).
Importing learners#
POST /v1/people/import adds up to 500 learners at once, as the console's class-list import does. Send the rows as JSON:
{
"unitId": "unit_8f2k...",
"rows": [
{ "name": "Ana Silva", "email": "ana@example.com", "grade": "9" },
{ "name": "Kit Lee" }
]
}- Each row gets its own result, in order (
results[].row), and one row failing never stops the others. - A row with an email is invited by email. Someone who already holds this place is left as it is (
created: false), so importing the same list twice adds nobody twice. - A row without an email gets a login card, only where accounts without email are switched on. Its PIN is in this answer only (
Cache-Control: no-store). Send anIdempotency-Key: a retry then gets the same answer, with the same cards, instead of making new ones. - A year or grade is a number or a range (
"5","7-9");"Year 5"and"Grade 5"are read as"5". A row with none takes the unit's own. - No email is sent. Each learner added gets a
member.addedevent.
A parent's or guardian's consent#
Where your organization works with children, a child's new place can wait for a parent's or guardian's consent (status: "waiting_consent"). Whether it waits follows your organization's type, the subjects open to the unit, the learner's year or grade, the consent age of your country, and your consent setting. A place that waits grants nothing yet, and PATCH can't switch it on.
POST /v1/memberships/{id}/guardian-consentrecords consent: who gave it (by:organisation,guardianorteacher), how (method), and your own reference to the evidence. The place turns active. A guardian's email, when you send one, is kept on the place as evidence and never appears in any event or webhook.DELETE /v1/memberships/{id}/guardian-consentwithdraws it. The place waits for consent again, and any conversation the learner is in ends at once. Nothing switches it back on (not your organization's own attestation, not earlier sessions) until consent is recorded again withPOST. The membership showsconsentWithdrawn: truemeanwhile.
consent.recorded and consent.withdrawn say who acted, never why and never the guardian's address. An adult learner (ageBand: "18+", where your organization marks adult learners) starts without a parent's consent.
Switching an account off#
PATCH /v1/people/{id} with { "disabled": true } switches a person's whole account off:
- no sign-in anywhere, and they stop counting as a staff member;
- any conversation they are in now ends;
- every record is kept, and their places keep their own status.
{ "disabled": false } switches it back on. Someone who holds a staff role counts as a staff member again, so it is refused with 403 upgrade_required when the plan has no room for another. The API never switches an owner's account off or on, nor your organization's last active owner. An account that belongs to another organization is switched off there; switch off their places here instead (409 conflict). Someone removed from the organization is added again with POST /v1/people.
To switch off just one place (a learner leaving one class), use PATCH /v1/memberships/{id} with { "status": "disabled" }. A learner's place switched off also ends a conversation they are in.
Events#
| Event | When |
|---|---|
unit.created, unit.renamed, unit.updated, unit.disabled, unit.enabled, unit.deleted | A unit changed. unit.updated also says when a join code was made or removed. |
member.added, member.updated, member.removed | A place was given, changed or removed. |
member.disabled, member.enabled | A place, or a whole account (scope: "account"), was switched off or on. |
consent.recorded, consent.withdrawn | A parent's or guardian's consent was recorded or withdrawn. |
Reading them through GET /v1/events needs people:read as well as events:read.