# Register a student

- Endpoint: `POST /v1/student`
- Production: `https://api.main-team.org/v1/student`
- Sandbox: `https://apisnd.main-team.org/v1/student`
- Operation: `registerStudent` (Students)
- Authentication: `Authorization: Bearer <token>`, a short-lived token you sign with your API key and secret
- Permission: `student/create:$org:$ID`

## Description

Creates a student under your account. From then on the student is one of yours: you can read and update them, send them a sign-in link, and apply them to exams.

**What to look up first**
- `country` is the `_id` of a country from `listCountries`. A name or an ISO code is refused.
- `grade` is the `_id` of a grade from `listGrades`, or its name, `1` to `12`.
- `city` is a city’s `_id` or its name within `country`, and `school` a school’s `_id` or its name within that country and city. There is no list of cities or schools: send the names your records hold. Names are matched without regard to case, and one that matches nothing is refused with 400. No country, grade, city or school is ever created.

**What is set for you**
- `username`: the country’s two-letter code, a letter and a number, such as `XXB1045` with a real code in place of `XX`. You cannot choose it, and it is how the student signs in and how support finds the account.
- `fullName`, from `firstName` and `lastName`.
- `activatedPlatformsThisSeason`: `["common"]` unless you send a list.
- `emailConfirmed`: `false`. Only the student can confirm their address.

**A password is optional, and needs a second permission.** Sending `password` needs `auth/signin` on `mto` as well as `student/create`; without it the request is refused with 403 before anything is read or written. It is stored hashed and never returned, and hashing is deliberately slow, so a registration that carries one takes a second or more longer. Leave it out to sign the student in with `createSigninLink` instead, or set one later with `setStudentPassword`.

**Not idempotent.** Registering an email address one of your students already has is refused with 409, and the message ends with that student’s `_id`, so a replayed batch can fetch the student rather than create a second one. An address another account registered is refused with 409 as well, without an id.

The answer has `country`, `city`, `school` and `grade` as ids; `getStudent` resolves them. An organization holds no record of the student until they have signed in there once, for instance through a link from `createSigninLink`, and until then `createApplication` on that organization is refused with 409.

## Request body

Required fields: `firstName`, `lastName`, `birth`, `sex`, `email`, `country`, `grade`, `school`, `city`.

```json
{
  "firstName": "Jane",
  "lastName": "Doe",
  "birth": "14/05/2008",
  "sex": "f",
  "email": "jane.doe@example.com",
  "email2": "<email2>",
  "phone": "+1 555 0100",
  "country": "6650a1b2c3d4e5f6a7b8c9d1",
  "grade": "10",
  "school": "Springfield High School",
  "city": "Springfield",
  "password": "correct horse battery staple",
  "activatedPlatformsThisSeason": [
    "common"
  ]
}
```

## Response

`201` Created: `message` is "User registered successfully.".

```json
{
  "success": true,
  "message": "User registered successfully.",
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 57,
    "totalPages": 3
  },
  "data": {
    "_id": "6650a1b2c3d4e5f6a7b8c9d0",
    "mainId": "6650a1b2c3d4e5f6a7b8c9d0",
    "username": "XXB1045",
    "firstName": "Jane",
    "lastName": "Doe",
    "fullName": "Jane Doe",
    "email": "jane.doe@example.com",
    "emailConfirmed": false,
    "phone": "+1 555 0100",
    "birth": "14/05/2008",
    "sex": "f",
    "country": "6650a1b2c3d4e5f6a7b8c9d1",
    "city": "6650a1b2c3d4e5f6a7b8c9d2",
    "school": "6650a1b2c3d4e5f6a7b8c9d3",
    "grade": "6650a1b2c3d4e5f6a7b8c9d4",
    "supervisor": "6650a1b2c3d4e5f6a7b8c9d5",
    "partner": "6650a1b2c3d4e5f6a7b8c9d6",
    "activatedPlatformsThisSeason": [
      "common"
    ],
    "createdAt": "2026-09-01T09:30:00.000Z",
    "updatedAt": "2026-09-02T14:05:00.000Z"
  }
}
```

## Errors

| Status | Code | When |
| --- | --- | --- |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | The body is not valid JSON, breaks a field’s rules, or has a field this operation does not accept ("property <name> should not exist"). |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `country` is not the `_id` of a country `listCountries` lists, or the country has no two-letter code to begin a username with. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `grade`, `city` or `school` matches nothing: no record has that `_id`, and none has that name (within `country`, and for a school within `city` too). |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `lastName` is missing or empty. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `birth` is not `DD/MM/YYYY`, or is not a date that exists, such as `31/02/2008`. |
| 400 | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `password` is shorter than 5 characters, longer than 72 bytes, or contains the student’s own name or email address. |
| 401 | [`unauthorized`](https://hub.main-team.org/api/errors#unauthorized) | The token is missing or malformed, is not signed with your account’s `apiSecret`, breaks the `iat` and `exp` rules, has expired or been revoked, or its account is not active. All of these answer the same. |
| 403 | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | The token is valid, but no role on your account allows `student/create` on `mto`, the organization every operation without `:organizationId` acts on, or a role denies it. |
| 403 | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | `password` was sent, and your account does not hold `auth/signin` on `mto`. Nothing was written. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | One of your students already has this email address. The message ends with that student’s `_id`. |
| 409 | [`conflict`](https://hub.main-team.org/api/errors#conflict) | Another account registered this email address. The message does not say whose it is. |
| 413 | [`payload_too_large`](https://hub.main-team.org/api/errors#payload_too_large) | The body is larger than 100 kB. |
| 415 | [`unsupported_media_type`](https://hub.main-team.org/api/errors#unsupported_media_type) | The body declares a charset that is not a UTF one (send UTF-8), or a `Content-Encoding` other than gzip, deflate or br. |
| 429 | [`too_many_requests`](https://hub.main-team.org/api/errors#too_many_requests) | Your account has made more than 100 requests to this operation in the current 60-second window. Wait the seconds in `Retry-After` before sending again. |
| 500 | [`internal_error`](https://hub.main-team.org/api/errors#internal_error) | Something failed on our side. Retry later, and quote `request_id` if it goes on. |

## Code samples

### curl

```bash
# $TOKEN: a short-lived token you minted with your API key and secret
curl -sS -X POST 'https://api.main-team.org/v1/student' \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  --data-binary @- <<'JSON'
{
  "firstName": "Jane",
  "lastName": "Doe",
  "birth": "14/05/2008",
  "sex": "f",
  "email": "jane.doe@example.com",
  "email2": "<email2>",
  "phone": "+1 555 0100",
  "country": "6650a1b2c3d4e5f6a7b8c9d1",
  "grade": "10",
  "school": "Springfield High School",
  "city": "Springfield",
  "password": "correct horse battery staple",
  "activatedPlatformsThisSeason": [
    "common"
  ]
}
JSON
```

### Node.js

```js
const token = process.env.TOKEN; // a short-lived token you minted with your API key and secret

const res = await fetch('https://api.main-team.org/v1/student', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${token}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "firstName": "Jane",
    "lastName": "Doe",
    "birth": "14/05/2008",
    "sex": "f",
    "email": "jane.doe@example.com",
    "email2": "<email2>",
    "phone": "+1 555 0100",
    "country": "6650a1b2c3d4e5f6a7b8c9d1",
    "grade": "10",
    "school": "Springfield High School",
    "city": "Springfield",
    "password": "correct horse battery staple",
    "activatedPlatformsThisSeason": [
      "common"
    ]
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`);
console.log(body.data);
```

### PHP

```php
<?php
$token = getenv('TOKEN'); // a short-lived token you minted with your API key and secret

$ch = curl_init('https://api.main-team.org/v1/student');
curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . $token,
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode([
        'firstName' => 'Jane',
        'lastName' => 'Doe',
        'birth' => '14/05/2008',
        'sex' => 'f',
        'email' => 'jane.doe@example.com',
        'email2' => '<email2>',
        'phone' => '+1 555 0100',
        'country' => '6650a1b2c3d4e5f6a7b8c9d1',
        'grade' => '10',
        'school' => 'Springfield High School',
        'city' => 'Springfield',
        'password' => 'correct horse battery staple',
        'activatedPlatformsThisSeason' => [
            'common',
        ],
    ]),
    CURLOPT_RETURNTRANSFER => true,
]);
$response = curl_exec($ch);
if ($response === false) {
    throw new RuntimeException(curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$body = json_decode($response, true);
if ($status >= 400) {
    $error = $body['error'];
    throw new RuntimeException("$status {$error['code']}: {$error['message']}");
}
print_r($body['data']);
```
