# Students

> Register students, read and update them, and understand the core record, organization copies, usernames, reference fields and duplicate emails.

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](https://hub.main-team.org/api/reference/register-student) | `POST /v1/student` | `student/create` on `mto` | the core record |
| [Check a registration](https://hub.main-team.org/api/reference/check-student-registration) | `POST /v1/student/check` | `student/create` on `mto` | nothing: it writes nothing |
| [List your students](https://hub.main-team.org/api/reference/list-students) | `GET /v1/student` | `student/read` on `mto` | the core record |
| [Get a student](https://hub.main-team.org/api/reference/get-student) | `GET /v1/student/<studentId>` | `student/read` on `mto` | the core record |
| [Update a student](https://hub.main-team.org/api/reference/update-student) | `PUT /v1/student/<studentId>` | `student/update` on `mto` | the core record |
| [List an organization's students](https://hub.main-team.org/api/reference/list-org-students) | `GET /v1/<organizationId>/student` | `student/read` on that organization | the core record, filtered |
| [Get a student on an organization](https://hub.main-team.org/api/reference/get-org-student) | `GET /v1/<organizationId>/student/<studentId>` | `student/read` on that organization | the core record, filtered |
| [Update a student on an organization](https://hub.main-team.org/api/reference/update-org-student) | `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](https://hub.main-team.org/api/permissions)). Three more
student operations have their own guides: [passwords](https://hub.main-team.org/api/guides/passwords),
[supervisor links](https://hub.main-team.org/api/guides/supervisors) and, for a class at a time,
[bulk registration](https://hub.main-team.org/api/guides/bulk-registration).

**Registering a roster?** `registerStudent` takes one student per request. For 30 to 1000 of them,
[createStudentImport](https://hub.main-team.org/api/reference/create-student-import) takes the whole list in one request,
checks it before it accepts it, and registers all of them or none. See
[Bulk registration](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/guides/sign-in-links) 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 `409` with 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 `mainId` when a record comes from an organization.** An application's `user`, or the
  student in a supervisor-link response, is the organization copy. Its `_id` means nothing elsewhere,
  but its `mainId` is the id you know. [Identifiers](https://hub.main-team.org/api/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>/student` and `GET /v1/<organizationId>/student/<studentId>` only return
  students whose list contains `"common"` or that organization's slug.
- A [sign-in link](https://hub.main-team.org/api/guides/sign-in-links) for an organization the student is not entered on is
  refused with `403` and `Student is not activated for organization <slug>.`
- The organization-wide application listings (`GET /v1/<organizationId>/application` and
  `GET /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.
- `null` is refused with `400` and `activatedPlatformsThisSeason 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](#update-through-an-organization)). 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.

```bash
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"
  }'
```

```json
{
  "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](#duplicate-email-addresses)). | `"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`](https://hub.main-team.org/api/guides/reference-data#countries). An id only: names and ISO codes are refused. | `"630e0182c53dc79a6836e67e"` |
| `grade` | string | yes | A grade `_id` from [`GET /v1/grade`](https://hub.main-team.org/api/guides/reference-data#grades), 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](#which-organizations-a-student-is-entered-on)). 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](https://hub.main-team.org/api/guides/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.

1. **Your token and permission.** `401` or `403` (`Insufficient role permissions`).
2. **The body.** `400` for a missing or malformed field, including a `birth` date that does not
   exist, or a property the API does not accept.
3. **The password permission.** If the body has a `password`, the account must also hold `auth/signin`
   on `mto`, or the answer is `403`.
4. **A duplicate on your account.** If one of your students already has this email: `409`, with that
   student's id in the message.
5. **Reference fields.** `country`, `grade`, `city` and `school` are resolved to stored ids, or the
   answer is `400` naming the field ([below](#how-reference-fields-resolve)).
6. **The username** is created ([Usernames](#usernames)).
7. **The write.** `409` if 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](#check-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

```js [Node.js]
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 [PHP]
<?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.

```bash
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"
  }'
```

```json
{
  "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`.

**`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 "` finds `TIRANA`. 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 `Springfield` is 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 `city` or `school` given by id is accepted if that
  record exists, whether or not it lies in the student's country. When you change a student's
  `country`, send `city` and `school` in the same request so the three still agree.
- **Some countries cannot be selected.** [`GET /v1/country`](https://hub.main-team.org/api/guides/reference-data#countries)
  does 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](mailto: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.

**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](#the-core-record-and-organization-copies)).
- **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](https://hub.main-team.org/api/guides/sign-in-links#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](https://hub.main-team.org/api/guides/sign-in-links) 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`, `S` and `T`.
- **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 `username` in any body is refused with
  `400`, `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](https://hub.main-team.org/api/guides/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`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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.

**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](https://hub.main-team.org/api/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

```bash
curl "https://api.main-team.org/v1/student?page=1&limit=50" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "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](https://hub.main-team.org/api/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.

```bash
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, so `ada+maths@example.org` sent
  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 answers `400` with
  `email must be given once, as one or more email addresses separated by commas` (or
  `email 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

```bash
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](#which-organizations-a-student-is-entered-on)). 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](#the-core-record-and-organization-copies)), and
[applications](https://hub.main-team.org/api/guides/applications) and [supervisor links](https://hub.main-team.org/api/guides/supervisors) there need
it. Add `signedIn=false` to list only the students who still have to sign in, and send each a
[sign-in link](https://hub.main-team.org/api/guides/sign-in-links); 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`.

```bash
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](#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](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/versioning)). The organization list adds one field to each student that is not
part of the record, `signedIn` ([Students on one organization](#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.

```bash
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" }'
```

```json
{
  "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](https://hub.main-team.org/api/guides/passwords). Rules to know:

- **The formats still apply** to whatever you send: `birth` as a `DD/MM/YYYY` date that exists
  (`31/02/2010` is refused), `sex` as `m`, `f` or `n`, `country` as an id, and `grade`, `city` and
  `school` resolving to something that exists.
- **`firstName`, `lastName`, `birth` and `sex` can be changed but not cleared.** `""` or `null` is
  refused with the field's rule, for example `lastName should not be empty`.
- **`phone` is cleared with `""`.** `null` is refused with `phone must be a string`.
- **`activatedPlatformsThisSeason` is added to the stored list**, never written over it
  ([Adding organizations](#adding-organizations)).
- **`email` cannot be removed.** `null` is refused with `email must be a valid email address`, and
  an invalid address with `email must be an email`.
- **Changing the email withdraws confirmation.** A new address has not been proved to belong to
  anyone, so `emailConfirmed` goes back to `false`, 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.
- **`password`** is refused with `property password should not exist`. So are `username`,
  `emailConfirmed` and every other field registration does not accept.

Refusals: `404`, code [`not_found`](https://hub.main-team.org/api/errors#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](#duplicate-email-addresses)).

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/update` on 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 `activatedPlatformsThisSeason` in 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](https://hub.main-team.org/api/guides/sign-in-links) 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`](https://hub.main-team.org/api/errors#bad_request) | `property <name> should not exist` | A field the route does not accept. | Remove it. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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](#which-organizations-a-student-is-entered-on). |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#bad_request) | `Invalid value for '_id': expected ObjectId.` | `<studentId>` is not 24 hex characters. | Use the `_id` from registration. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#forbidden) | `Insufficient role permissions` | The account lacks the permission, or holds it on another organization. | Ask the operator for the role. |
| `403` | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | `Setting a student's password needs the auth/signin permission on mto, …` | `password` in a registration without `auth/signin` on `mto`. | See [Passwords](https://hub.main-team.org/api/guides/passwords). |
| `404` | [`not_found`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#not_found) | `Organization not found!` | `<organizationId>` is not an organization `_id`. | Use the `_id` from `GET /v1/organization`, never the slug. |
| `409` | [`conflict`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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](https://hub.main-team.org/api/tutorials/register-and-apply): the whole
  flow end to end, in Node.js and PHP.
- [Reference data](https://hub.main-team.org/api/guides/reference-data): countries, grades and organizations.
- [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links): how a student gets into the panel, and how their first
  sign-in creates an organization's copy.
- [Bulk registration](https://hub.main-team.org/api/guides/bulk-registration): 30 to 1000 students in one request, all of
  them or none.
- [Passwords](https://hub.main-team.org/api/guides/passwords) and [Supervisors](https://hub.main-team.org/api/guides/supervisors).
