Guides
Students
Almost every integration starts with a student. You register the student once, keep your own record of
the _id the API gives back, and use that id everywhere afterwards: to send the student into the
panel, to enter them for exams, and to collect their certificates and reports.
"Your students" means the students your API account registered. You can read and change those and no others. A student registered by another account behaves exactly as if it did not exist, and so does a student on your old account if an operator issues you a new one.
The operations
| Operation | Route | Permission | Acts on |
|---|---|---|---|
| Register a student | POST /v1/student | student/create on mto | the core record |
| Check a registration | POST /v1/student/check | student/create on mto | nothing: it writes nothing |
| List your students | GET /v1/student | student/read on mto | the core record |
| Get a student | GET /v1/student/<studentId> | student/read on mto | the core record |
| Update a student | PUT /v1/student/<studentId> | student/update on mto | the core record |
| List an organization's students | GET /v1/<organizationId>/student | student/read on that organization | the core record, filtered |
| Get a student on an organization | GET /v1/<organizationId>/student/<studentId> | student/read on that organization | the core record, filtered |
| Update a student on an organization | PUT /v1/<organizationId>/student/<studentId> | student/update on that organization | the core record |
A role grants a permission "on mto" when its target is mto or *, and "on that organization" when
its target is that organization's slug or * (Permissions). Three more
student operations have their own guides: passwords,
supervisor links and, for a class at a time,
bulk registration.
Note
Registering a roster? registerStudent takes one student per request. For 30 to 1000 of them,
createStudentImport takes the whole list in one request,
checks it before it accepts it, and registers all of them or none. See
Bulk registration.
The core record and organization copies
A student exists in more than one place, and knowing which one an operation touches explains most of the rules on this page.
The core record is created when you register the student. It lives on mto, the core
organization, and holds the student's profile, username and password. Every student operation on
this page reads or writes the core record, including the ones with an <organizationId> in the
path. The _id you get at registration is the core record's id, and it is the <studentId> every
student route takes.
An organization copy is the record one organization (stem, hilingua, neo, gmath or coding) keeps
of the same student. It is created the first time the student signs in to that organization, through
a sign-in link you create or on their own. The API cannot create one any
other way. A copy has its own _id, different on every organization, and carries the core record's
_id in mainId.
| Core record | Organization copy | |
|---|---|---|
| Created | When you call POST /v1/student | At the student's first sign-in to that organization |
| Id | _id: the id you store and pass as <studentId> | Its own _id on that organization; mainId = the core _id |
| Written by the student routes | Yes | No |
| Needed for | Everything on this page | Applications, supervisor links, and the student's certificates and reports on that organization |
| Kept in step | Profile details are copied from the core record each time the student signs in there |
What this means in practice:
- A new student has no copies. Until the student has signed in to an organization, routes that
need the copy refuse. For example, creating an application answers
409with a message saying the student has never signed in to that organization. The fix is always the same: create a sign-in link for that organization and have the student open it. - Your changes reach the checks this API makes at once. Exam eligibility, for example, is judged from the core record's grade and country. An organization's own panel shows the student as its copy holds them, which picks up your change the next time the student signs in there.
- Match on
mainIdwhen a record comes from an organization. An application'suser, or the student in a supervisor-link response, is the organization copy. Its_idmeans nothing elsewhere, but itsmainIdis the id you know. Identifiers covers this in detail.
Which organizations a student is entered on
activatedPlatformsThisSeason lists the organizations the student is entered on this season:
| Value | Meaning |
|---|---|
"common" | Every organization. This is the default when you register a student without the field. |
"stem", "hilingua", "neo", "gmath", "coding" | That organization. |
The values are lower case. Anything else is refused with 400 and
each value in activatedPlatformsThisSeason must be one of the following values: common, stem, hilingua, neo, gmath, coding,
and that includes "mto": every student is on the core record already. Several values can be
combined: ["stem", "neo"]. At registration an empty list, [], is accepted and enters the
student on no organization: the organization routes below leave them out, and sign-in links are
refused with 403 until you add one.
The list decides which organization routes treat the student as yours:
GET /v1/<organizationId>/studentandGET /v1/<organizationId>/student/<studentId>only return students whose list contains"common"or that organization's slug.- A sign-in link for an organization the student is not entered on is
refused with
403andStudent is not activated for organization <slug>. - The organization-wide application listings (
GET /v1/<organizationId>/applicationandGET /v1/<organizationId>/application/exam-applications/<examId>) only include students entered on that organization.
Adding organizations
The list only ever grows. Both updates add to it, and no operation removes an organization from it:
| You call | With activatedPlatformsThisSeason in the body | Without it |
|---|---|---|
PUT /v1/student/<studentId> | The values you send are added to the list. | Unchanged. |
PUT /v1/<organizationId>/student/<studentId> | The values you send are added to the list. The organization in the path is added only if you name it. | That organization is added, unless the list already contains "common". |
- A value the list already holds is not added twice, so sending the same list again changes nothing.
[]adds nothing.nullis refused with400andactivatedPlatformsThisSeason must be an array, as it is at registration.- A slug added to a list that holds
"common"is stored beside it. That changes nothing, because"common"already covers every organization.
To add one organization, send just its slug, or send that organization's update route an empty body (below). You never need to read the current list first.
Register a student
POST /v1/student creates the core record and answers 201 with the new student.
curl -X POST "https://api.main-team.org/v1/student" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada.lovelace@example.org",
"birth": "14/05/2010",
"sex": "f",
"country": "630e0182c53dc79a6836e67e",
"grade": "9",
"city": "Berlin",
"school": "Berlin International School",
"phone": "+49 30 123 4567"
}'
{
"success": true,
"message": "User registered successfully.",
"data": {
"_id": "6650f1a2b3c4d5e6f7a8b9c0",
"username": "XXB1045",
"firstName": "Ada",
"lastName": "Lovelace",
"fullName": "Ada Lovelace",
"email": "ada.lovelace@example.org",
"emailConfirmed": false,
"phone": "+49 30 123 4567",
"birth": "14/05/2010",
"sex": "f",
"country": "630e0182c53dc79a6836e67e",
"city": "6650e0a1b2c3d4e5f6a7b801",
"school": "6650e0a1b2c3d4e5f6a7b8c2",
"grade": "630e01826836e67ec53dc7a5",
"activatedPlatformsThisSeason": ["common"],
"createdAt": "2026-09-15T09:12:44.512Z",
"updatedAt": "2026-09-15T09:12:44.512Z"
}
}
Store data._id. It is the student's id on every route from now on. Note that city, school and
grade come back as ids even though the request named them: the API resolves every reference to the
stored id before it writes anything.
Fields
| Field | Type | Required | Format and rules | Example |
|---|---|---|---|---|
firstName | string | yes | Not empty. | "Ada" |
lastName | string | yes | Not empty. Without it the registration is refused with 400 and lastName should not be empty. | "Lovelace" |
email | string | yes | A valid email address, with no spaces around it (" ada@example.org" is refused as not an address). Stored in lower case. Must not belong to any other student on the platform (Duplicates). | "ada.lovelace@example.org" |
birth | string | yes | DD/MM/YYYY: two-digit day 01–31, two-digit month 01–12, four-digit year, and a date that exists on the calendar. 31/02/2010 and ISO dates such as 2010-05-14 are refused with birth must be a real date in DD/MM/YYYY format, at registration and on both updates. | "14/05/2010" |
sex | string | yes | Exactly "m", "f" or "n", in lower case. | "f" |
country | string | yes | A country _id from GET /v1/country. An id only: names and ISO codes are refused. | "630e0182c53dc79a6836e67e" |
grade | string | yes | A grade _id from GET /v1/grade, or the grade's name. Always a string: the number 9 is refused with grade must be a string. | "9" |
city | string | yes | A city's _id, or its name within country. | "Berlin" |
school | string | yes | A school's _id, or its name within country and city. | "Berlin International School" |
phone | string | no | Any string; not validated. null is refused with phone must be a string: leave the field out instead. International format is the most useful to everyone who reads it. | "+49 30 123 4567" |
activatedPlatformsThisSeason | array of strings | no | "common" or organization slugs (above). Defaults to ["common"]. null is refused with activatedPlatformsThisSeason must be an array: leave the field out instead. | ["common"] |
password | string | no | Only from an account that also holds auth/signin on mto; 5 characters to 72 bytes; see Passwords. Never returned. | "grapefruit lantern quarry" |
Leave email2 out. It exists to catch automated form spam, and a registration that fills it in is
refused.
Every other property is refused with 400 and property <name> should not exist, naming it. That
includes fields a student record has but you may not set: username, fullName, emailConfirmed,
supervisor, mainId, userType and roles. The API sets those itself, or they belong to the
student and the platform.
fullName is derived from firstName and lastName ("Ada Lovelace"), and kept in step when either
changes. Certificates and reports print it.
What happens, in order
Each step runs only if the one before it passed, and no student is created unless every step passes.
- Your token and permission.
401or403(Insufficient role permissions). - The body.
400for a missing or malformed field, including abirthdate that does not exist, or a property the API does not accept. - The password permission. If the body has a
password, the account must also holdauth/signinonmto, or the answer is403. - A duplicate on your account. If one of your students already has this email:
409, with that student's id in the message. - Reference fields.
country,grade,cityandschoolare resolved to stored ids, or the answer is400naming the field (below). - The username is created (Usernames).
- The write.
409if the email belongs to a student on another account.
When a body breaks several rules, the error message names one of them. Fix it and send again. Checking a registration first runs steps 2, 4 and 5, and the part of step 6 that needs the country, without creating anyone, and names every reference that fails.
Handling the answers
const BASE = 'https://api.main-team.org/v1';
export async function registerStudent(token, student) {
const res = await fetch(`${BASE}/student`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(student),
});
const body = await res.json();
if (res.status === 201) return { created: true, student: body.data };
const { code, message, request_id } = body.error;
if (res.status === 409) {
// Already one of yours: the message ends with "(<studentId>)".
const mine = message.match(/\(([0-9a-f]{24})\)\.$/);
if (mine) return { created: false, existingId: mine[1] };
// Held by a student on another account: this address cannot be used.
throw new Error(`Email in use elsewhere: ${student.email} (request ${request_id})`);
}
throw new Error(`${res.status} ${code}: ${message} (request ${request_id})`);
}
<?php
const MT_BASE = 'https://api.main-team.org/v1';
function mt_register_student(string $token, array $student): array
{
$ch = curl_init(MT_BASE . '/student');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($student),
CURLOPT_TIMEOUT => 30,
]);
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$body = json_decode($raw, true);
if ($status === 201) {
return ['created' => true, 'student' => $body['data']];
}
$error = $body['error'];
if ($status === 409) {
// Already one of yours: the message ends with "(<studentId>)".
if (preg_match('/\(([0-9a-f]{24})\)\.$/', $error['message'], $m)) {
return ['created' => false, 'existingId' => $m[1]];
}
throw new RuntimeException("Email in use elsewhere: {$student['email']} (request {$error['request_id']})");
}
throw new RuntimeException("$status {$error['code']}: {$error['message']} (request {$error['request_id']})");
}
Check a registration first
POST /v1/student/check takes the body you would send to POST /v1/student, without password, and
runs the checks registration makes before it creates anything: the body's rules, a duplicate among
your students, and the four reference fields. It creates nothing, issues no username, and can be sent
any number of times. It needs the permission registration needs, student/create on mto. Use it to
validate a batch, or a form, before you register anyone.
curl -X POST "https://api.main-team.org/v1/student/check" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"firstName": "Ada",
"lastName": "Lovelace",
"email": "ada.lovelace@example.org",
"birth": "14/05/2010",
"sex": "f",
"country": "630e0182c53dc79a6836e67e",
"grade": "9",
"city": "Berlin",
"school": "Berlin Harbour School"
}'
{
"success": true,
"message": "Registration checked. Nothing was created.",
"data": {
"valid": false,
"problems": [
{
"field": "school",
"message": "school is not a known school. This API does not create reference data."
}
],
"resolved": {
"country": "630e0182c53dc79a6836e67e",
"grade": "630e01826836e67ec53dc7a5",
"city": "6650e0a1b2c3d4e5f6a7b801",
"school": null
},
"duplicate": { "sameAccount": false }
}
}
| Field | Meaning |
|---|---|
valid | true when POST /v1/student with the same body would pass every check made here. |
problems | Every reference field that matches nothing, as { field, message }, with the message registration would refuse it with. Registration names only the first; the check names them all, in the order country, grade, city, school. A city or school named inside a country or city that matched nothing is not looked up, so fix the field above it first. A country whose students cannot be given a username is listed as well. |
resolved | The _id each reference resolved to, which is what registration would store, or null. |
duplicate | sameAccount is true when one of your students already has this email address, and studentId is that student's _id: registration would answer 409. Nothing else is looked up then, so problems is empty and every resolved value is null. |
The answer is 200 whatever the lookups found. A body that breaks a field's rule is refused with
400, exactly as registration refuses it, and so is password, with
property password should not exist.
Note
valid: true is not a promise. Only your own students are checked for the email address. An
address a student on another account holds is not looked for, so registration can still answer
409 with That email address is already registered. The check also cannot see a student
registered, or reference data changed, after it ran.
How reference fields resolve
country, grade, city and school point at the platform's reference data. Whatever form you send,
the API looks it up and stores the matching record's id. A value that matches nothing is refused: the
API never invents or creates reference data.
| Field | Accepts | A name is looked up | Refused with 400 when |
|---|---|---|---|
country | an id only | (names are not accepted) | not an id: country must be a mongodb id; an id that matches nothing, or a country that cannot be selected: country is not a known country. |
grade | an id or a name | among all grades | no grade has that id: grade is not a known grade.; or that name: grade is not a known grade. This API does not create reference data. |
city | an id or a name | within the student's country | no match: city is not a known city. (id) or city is not a known city. This API does not create reference data. (name) |
school | an id or a name | within the student's country and city | no match: school is not a known school. (id) or school is not a known school. This API does not create reference data. (name) |
The details:
- Id or name is decided per value. A 24-character hex string is treated as an id and looked up as one. Anything else is treated as a name. You can mix the forms freely, for example a country id with a city name.
- Names ignore case and surrounding spaces.
" tirana "findsTIRANA. Reference names are stored in upper case, which is how they come back in reads. - Names are looked up inside the fields above them, in the order country, city, school. A city
named
Springfieldis only looked for in the student's country, and a school in that country and city. An exact match always wins. Failing that, a city that is tied to no country matches, and a school in the right country that is tied to no city matches. Some older entries are stored that way. - An id is only checked for existence. A
cityorschoolgiven by id is accepted if that record exists, whether or not it lies in the student's country. When you change a student'scountry, sendcityandschoolin the same request so the three still agree. - Some countries cannot be selected.
GET /v1/countrydoes not list them, and a write that names one is refused exactly like an unknown id. - There is no list of cities or schools. Use the names your own records hold. If the platform does not know a real city or school, the API cannot add it: write to info@main-team.org.
- On an update, a city or school name is resolved against the country and city in the same body or, when you leave them out, against the ones the student already has. You can change the school without repeating the country.
Note
Why the grade is strict. Every exam is open to a set of grades, and grade ids are the same on every organization, so a student's grade is compared directly with the exam's. A grade the platform does not know would be a student no exam could ever accept, so an unknown grade is refused, never created.
What the API never creates
- Reference data. No country, grade, city or school is ever created, whatever you send.
- Users other than students. Supervisors, parents and staff register through the platform. The API manages students and nothing else.
- Organization copies. Only the student's first sign-in to an organization creates one (above).
- Usernames of your choosing. The API mints every username; you cannot set or change one.
- Email confirmation. The API never marks an address as confirmed. A student confirms their own address in the panel, with a 6-digit code emailed to them, and the panel asks for it on every page until they do. See Email confirmation.
- Messages to the student. Registering a student through the API does not email them. Tell them yourself how they will get in; usually that is a sign-in link from your own product.
Usernames
Every student gets a username when they are registered, and it is in every student response as
username. It is the identifier students and support staff use to refer to an account.
The format is the country's two-letter code, one letter, then a number. In XXB1045, XX stands for
the country's code, B is the letter and 1045 the number. These pages write XX, which is no
country's code, so that no example is a real student's username.
- The letter is chosen at random from A to Z, leaving out
P,SandT. - The number counts up per country and letter, starting at
1000, so usernames are unique within that series. - Usernames are upper case, and read-only. Sending
usernamein any body is refused with400,property username should not exist. - A username never changes through the API, not even when you change the student's country.
Because a username identifies the student to other people, it is not a secret. Never use it as, or in, a password. The API refuses passwords that contain it (Passwords).
Duplicate email addresses
An email address belongs to one student on the whole platform. Two refusals follow from that, and they differ in whether they tell you who holds the address:
| Situation | Status | Message |
|---|---|---|
| One of your students already has the address | 409 conflict | A student with that email is already registered to this account (6650f1a2b3c4d5e6f7a8b9c0). |
| A student on another account, or the platform's own users, has it | 409 conflict | That email address is already registered. |
In the first case, the id in the message is your existing student: fetch it with
GET /v1/student/<id>, or update it. In the second case you cannot see or claim that student. Ask for
a different address.
Addresses are compared without regard to letter case: Ada.Lovelace@Example.org is the same address
as ada.lovelace@example.org. An address with spaces around it is refused with 400 before any
comparison, so trim addresses on your side.
Warning
Registration is not idempotent. Sending the same registration twice never creates two students.
The second attempt is refused with 409, which is how you know. If a registration times out,
retry it: a 409 that names an id means the first attempt worked, and that id is your student. If two
identical registrations run at the same moment, the loser may get the message without an id. Look the
student up in your list before you treat the address as taken elsewhere.
Retries and idempotency covers this pattern.
An update that moves a student to an address another student already holds is refused with 409 and
That email address is already registered., without an id, even when the other student is one of
yours.
Read students
All your students
curl "https://api.main-team.org/v1/student?page=1&limit=50" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Students fetched successfully.",
"data": [
{
"_id": "6650f1a2b3c4d5e6f7a8b9c0",
"username": "XXB1045",
"firstName": "Ada",
"lastName": "Lovelace",
"fullName": "Ada Lovelace",
"email": "ada.lovelace@example.org",
"emailConfirmed": false,
"phone": "+49 30 123 4567",
"birth": "14/05/2010",
"sex": "f",
"country": {
"_id": "630e0182c53dc79a6836e67e",
"name": "GERMANY",
"iso2": "DE",
"iso3": "DEU",
"createdAt": "2022-08-30T12:14:26.118Z",
"updatedAt": "2022-08-30T12:14:26.118Z"
},
"city": {
"_id": "6650e0a1b2c3d4e5f6a7b801",
"name": "TIRANA",
"country": "630e0182c53dc79a6836e67e"
},
"school": {
"_id": "6650e0a1b2c3d4e5f6a7b8c2",
"name": "TIRANA INTERNATIONAL SCHOOL",
"country": "630e0182c53dc79a6836e67e",
"city": "6650e0a1b2c3d4e5f6a7b801"
},
"grade": {
"_id": "630e01826836e67ec53dc7a5",
"name": "9",
"createdAt": "2022-08-30T12:14:26.309Z",
"updatedAt": "2022-08-30T12:14:26.309Z"
},
"supervisor": {
"_id": "64c1d2e3f4a5b6c7d8e9f0a1",
"firstName": "Deniz",
"lastName": "Kaya",
"fullName": "Deniz Kaya",
"username": "XXT1003"
},
"activatedPlatformsThisSeason": ["common"],
"createdAt": "2026-09-15T09:12:44.512Z",
"updatedAt": "2026-09-15T09:12:44.512Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 312, "totalPages": 7 }
}
Paginate with page (default 1) and limit (default 20, at most 100; a larger value is lowered to
100). The list has no guaranteed order. Pagination shows complete loops, and what
to expect if students are registered while you page.
To find students by email address, add email: one address, or up to 100 separated by commas. Only
your students with one of them are listed, matched exactly and without regard to case, and
pagination.total counts the matches.
curl "https://api.main-team.org/v1/student?email=ada.lovelace@example.org,grace.hopper@example.org" \
-H "Authorization: Bearer $TOKEN"
- An address none of your students has matches nothing, whoever else holds it. The filter never looks beyond your own students.
- Encode
+as%2B. In a query string+reads as a space, soada+maths@example.orgsent as it is arrives as no address at all and is refused. - An unreadable filter is refused, never ignored: an empty
email, one given twice, an entry that is not an address, or more than 100 entries answers400withemail must be given once, as one or more email addresses separated by commas(oremail must list at most 100 addresses). - Addresses in a URL are more likely to be recorded along the way than a body. Use the filter where that is acceptable to you.
One student
curl "https://api.main-team.org/v1/student/6650f1a2b3c4d5e6f7a8b9c0" \
-H "Authorization: Bearer $TOKEN"
The answer is 200 with the student in data, shaped like one element of the list above, and the
message Student fetched successfully.
A student that is not yours answers 404 not_found, Student not found!, the same as an id that
matches no student. An id that is not 24 hex characters is 400
(Invalid value for '_id': expected ObjectId.).
Students on one organization
GET /v1/<organizationId>/student and GET /v1/<organizationId>/student/<studentId> return the same
core records, filtered to the students entered on that organization
(Which organizations). The responses have the same
shape as the flat routes. The single read takes the same <studentId>, the core _id, and answers
404 not_found, Student not found!, when the student is not yours or not entered on that
organization.
Use these routes when your account's permissions are scoped to an organization, or when you want
exactly the students an organization treats as yours. For your full list, use GET /v1/student.
The organization list also says, for each student, whether they have signed in to that
organization at least once: signedIn is true or false. The organization keeps its own record of
a student from that first sign-in (above), and
applications and supervisor links there need
it. Add signedIn=false to list only the students who still have to sign in, and send each a
sign-in link; add signedIn=true for the students ready for
applications. The filter narrows pagination.total too. Any other value is refused with 400 and
signedIn must be true or false. The single read, GET /v1/<organizationId>/student/<studentId>,
does not carry signedIn.
curl "https://api.main-team.org/v1/64b7f0c2a1d3e4f5a6b7c8d9/student?signedIn=false" \
-H "Authorization: Bearer $TOKEN"
Fields returned
A student comes back with at most these fields, on every route that returns one. A field the student
does not have, such as a phone never given, is left out rather than sent empty.
| Field | Type | Meaning |
|---|---|---|
_id | string | The student's id. On the student routes, the core record's id. |
mainId | string | On an organization copy, the core record's _id. The student routes return the core record, where it is usually absent. |
username | string | Minted at registration; read-only (Usernames). |
firstName, lastName | string | As you set them. |
fullName | string | firstName and lastName joined by a space; derived. |
email | string | Trimmed, lower case. |
emailConfirmed | boolean | true once the student has proved the address is theirs. Starts false. |
phone | string | As you set it. |
birth | string | DD/MM/YYYY. |
sex | string | m, f or n. |
country, city, school, grade | object on reads, id on writes | On the read routes, the full reference record. On registration and updates, its id. |
supervisor | object on reads, id on writes | The supervisor on the core record, as _id, firstName, lastName, fullName and username. Links made per organization do not appear here (Supervisors). |
partner | object on reads, id on writes | A partner account the student is attached to on the platform, shaped like supervisor. |
activatedPlatformsThisSeason | array of strings | The organizations the student is entered on. |
createdAt, updatedAt | ISO 8601 timestamp |
Nothing else a student record holds is ever returned: not the password, and not your account's
details. Treat the list as open-ended all the same, and ignore fields you do not recognize
(Versioning). The organization list adds one field to each student that is not
part of the record, signedIn (Students on one organization).
Update a student
PUT /v1/student/<studentId> changes the fields you send and leaves every other field as it is. No
field is required, but each one you send follows its registration rule.
curl -X PUT "https://api.main-team.org/v1/student/6650f1a2b3c4d5e6f7a8b9c0" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "grade": "10", "school": "Berlin Science High School" }'
{
"success": true,
"message": "Student updated successfully.",
"data": {
"_id": "6650f1a2b3c4d5e6f7a8b9c0",
"username": "XXB1045",
"firstName": "Ada",
"lastName": "Lovelace",
"fullName": "Ada Lovelace",
"email": "ada.lovelace@example.org",
"emailConfirmed": false,
"phone": "+49 30 123 4567",
"birth": "14/05/2010",
"sex": "f",
"country": "630e0182c53dc79a6836e67e",
"city": "6650e0a1b2c3d4e5f6a7b801",
"school": "6650e0a1b2c3d4e5f6a7b8d7",
"grade": "630e01826836e67ec53dc7a6",
"activatedPlatformsThisSeason": ["common"],
"createdAt": "2026-09-15T09:12:44.512Z",
"updatedAt": "2026-09-16T07:40:02.931Z"
}
}
The same fields as registration are accepted, except password, which has
its own route. Rules to know:
- The formats still apply to whatever you send:
birthas aDD/MM/YYYYdate that exists (31/02/2010is refused),sexasm,forn,countryas an id, andgrade,cityandschoolresolving to something that exists. firstName,lastName,birthandsexcan be changed but not cleared.""ornullis refused with the field's rule, for examplelastName should not be empty.phoneis cleared with"".nullis refused withphone must be a string.activatedPlatformsThisSeasonis added to the stored list, never written over it (Adding organizations).emailcannot be removed.nullis refused withemail must be a valid email address, and an invalid address withemail must be an email.- Changing the email withdraws confirmation. A new address has not been proved to belong to
anyone, so
emailConfirmedgoes back tofalse, and the panel asks the student to confirm the new address before they can use it again. Sending the address the student already has, in any letter case, is not a change and leaves confirmation alone. That makes it safe to send a whole profile on every sync. - Renaming updates
fullName, whether you change one name or both. passwordis refused withproperty password should not exist. So areusername,emailConfirmedand every other field registration does not accept.
Refusals: 404, code not_found, Student not found! when the student is
not yours (or does not exist). 400 for a malformed id or a field that breaks its rule. 409 for an
email another student holds (Duplicates).
The response is the student after the change. As with registration, reference fields come back as ids.
Update through an organization
PUT /v1/<organizationId>/student/<studentId> writes the same core record, with the same fields,
rules and refusals as the flat update. Two things differ:
- The permission is
student/updateon the organization in the path, so an account whose roles are scoped to one organization can still keep its students' details current. - It enters the student on that organization. Without
activatedPlatformsThisSeasonin the body, the organization in the path is added to the student's list, unless the list already contains"common". Updating the same student again adds nothing further. This is how you give a student access to an organization before creating a sign-in link for it.
The student does not need to be entered on the organization already, and the call does not touch the organization's copy of the student. The copy picks up the changes at the student's next sign-in there.
The body may be empty. {} enters the student on the organization, changes nothing else, and answers
200 with the student. If you send activatedPlatformsThisSeason, its values are added instead, and
this organization is added only if you name it.
Flat and per-organization operations compared
Flat (/v1/student…) | Per organization (/v1/<organizationId>/student…) | |
|---|---|---|
| Record | The core record | The core record |
| Permission target | mto or * | That organization's slug or * |
| Register | Yes | No; register on the flat route |
| List and read | All your students | Your students entered on that organization |
| Update | Changes the fields you send | Changes the fields you send, and enters the student on that organization |
| Set a password | PUT /v1/student/<studentId>/password | Not available |
| Link a supervisor | Not available | PUT /v1/<organizationId>/student/<studentId>/supervisor |
Errors
| Status | Code | Message | Cause | Fix |
|---|---|---|---|---|
400 | bad_request | property <name> should not exist | A field the route does not accept. | Remove it. |
400 | bad_request | <field> should not be empty, email must be an email, email must be a valid email address, birth must be a real date in DD/MM/YYYY format, country must be a mongodb id, grade must be a string, … | A field is missing or malformed. | Fix the field named. |
400 | bad_request | phone must be a string | phone was sent as null. | Leave it out, or on an update send "" to clear it. |
400 | bad_request | activatedPlatformsThisSeason must be an array, each value in activatedPlatformsThisSeason must be one of the following values: … | The list is null, or holds a value other than common and the five slugs. | Leave it out, or send slugs from the list above. |
400 | bad_request | <field> is not a known <field>. (and This API does not create reference data. for a name) | A reference value matches nothing, or names a country that cannot be selected. | Take ids from the reference routes; check spelling and the student's country. |
400 | bad_request | Invalid value for '_id': expected ObjectId. | <studentId> is not 24 hex characters. | Use the _id from registration. |
400 | bad_request | email must be given once, as one or more email addresses separated by commas, email must list at most 100 addresses | The email filter of GET /v1/student could not be read. | Send one comma-separated list of up to 100 addresses, with + encoded as %2B. |
400 | bad_request | signedIn must be true or false | The signedIn filter of GET /v1/<organizationId>/student is another value. | Send true or false, or leave it out. |
400 | bad_request | property password should not exist | password was sent to POST /v1/student/check. | Leave it out; send it with the registration. |
403 | forbidden | Insufficient role permissions | The account lacks the permission, or holds it on another organization. | Ask the operator for the role. |
403 | forbidden | Setting a student's password needs the auth/signin permission on mto, … | password in a registration without auth/signin on mto. | See Passwords. |
404 | not_found | Student not found! | A read or update of a student that is not yours or does not exist, or a read through an organization the student has no access to. | Check the id and the account. |
404 | not_found | Organization not found! | <organizationId> is not an organization _id. | Use the _id from GET /v1/organization, never the slug. |
409 | conflict | A student with that email is already registered to this account (<id>). | The address is one of your students'. | Use that student. |
409 | conflict | That email address is already registered. | The address belongs to a student elsewhere. | Ask for another address. |
Related
- Tutorial: register a student and apply for an exam: the whole flow end to end, in Node.js and PHP.
- Reference data: countries, grades and organizations.
- Sign-in links: how a student gets into the panel, and how their first sign-in creates an organization's copy.
- Bulk registration: 30 to 1000 students in one request, all of them or none.
- Passwords and Supervisors.