# Identifiers

> Which id to send where. Covers organization ids, the student id and organization copies, mainId, per-organization ids, shortId and usernames.

Almost every mistake in a new integration is the wrong id in the right place. This page explains every kind of id the API uses, where you get each one, where you send it, and the one id that has two faces: the student.

Every id is a 24-character lowercase hexadecimal string, such as `652f1c9b8e4b2a0012a3c4d5`. Treat ids as opaque strings:

- **You never make one up.** Every id you send came from an earlier response.
- **Compare them as strings.** They are always lowercase.
- **Store them as text**, for example in a `CHAR(24)` column.
- **Read nothing into them.** An id's characters carry no meaning you should rely on, dates included.

## The ids at a glance

| Id | Where you get it | Where you send it | Valid in |
|---|---|---|---|
| Organization `_id` | [listOrganizations](https://hub.main-team.org/api/reference/list-organizations) | `<organizationId>` in organization paths | Every call |
| Student id (the student's `_id`) | `data._id` from [registerStudent](https://hub.main-team.org/api/reference/register-student), or [listStudents](https://hub.main-team.org/api/reference/list-students) | `<studentId>` and `<userId>` in paths, `studentId` in bodies | Every organization |
| Organization copy `_id` | Inside applications, certificates and payments | Never | One organization |
| `mainId` | On organization copies | Never. It **is** the student id | – |
| Country `_id` | [listCountries](https://hub.main-team.org/api/reference/list-countries) | `country` when registering a student, or on either student update | Student records. Exams use each organization's own country records (see [Countries](#countries)) |
| Grade `_id` | [listGrades](https://hub.main-team.org/api/reference/list-grades) | `grade` when registering or updating a student | Every organization, unchanged |
| Exam, category, session and language `_id` | [listExams](https://hub.main-team.org/api/reference/list-exams), [listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams), [listExamCategories](https://hub.main-team.org/api/reference/list-exam-categories) | `examId` in bodies, `<examId>` and `<categoryId>` in paths | One organization |
| Application `_id` | [createApplication](https://hub.main-team.org/api/reference/create-application) and the application lists | `<applicationId>` in paths | One organization |
| Certificate and report `_id`, `shortId` | [listStudentCertificates](https://hub.main-team.org/api/reference/list-student-certificates), [listStudentReports](https://hub.main-team.org/api/reference/list-student-reports) | `<certificateId>`, `<reportId>` on downloads | One organization |
| Username | Every student record | A supervisor's username, in `supervisorUsername` | – |

## Organization ids

Every organization path starts with `/v1/<organizationId>/`. The value is the organization's `_id`. Get the ids once, from the organization list:

```bash
curl -sS "https://api.main-team.org/v1/organization" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "success": true,
  "message": "Organizations fetched successfully.",
  "data": [
    {
      "_id": "64f0a1b2c3d4e5f6a7b8c9d1",
      "name": "STEM Olympiad",
      "slug": "stem",
      "logo": "https://cdn.example.com/logos/stem.png",
      "desc": "Science, technology, engineering and mathematics.",
      "defaultRedirect": "/"
    },
    {
      "_id": "64f0a1b2c3d4e5f6a7b8c9d2",
      "name": "Hi-Lingua",
      "slug": "hilingua",
      "logo": "https://cdn.example.com/logos/hilingua.png",
      "desc": "International language olympiad.",
      "defaultRedirect": "/"
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 5, "totalPages": 1 }
}
```

Each organization comes back with `_id`, `name`, `slug`, `logo`, `desc` and `defaultRedirect`. The list holds the five organizations that run exams, with the slugs `stem`, `hilingua`, `neo`, `gmath` and `coding`. `mto`, the core record, is not listed: the flat paths act on it and take no organization id.

- **The slug tells you which organization it is. The `_id` is what goes in the path.** `/v1/stem/exam` is not a valid path: it answers `404 not_found`, `Organization not found!`.
- **A malformed or unknown organization id**, or the id of an organization `GET /v1/organization` does not list, answers the same `404`, `Organization not found!`. The API checks it only after it has accepted your token, so a request without a valid token gets `401` whatever the id.
- **Flat paths take no organization id.** `/v1/student`, `/v1/country` and the other flat paths act on `mto`. See [Organizations](https://hub.main-team.org/api/organizations).

Build a slug-to-id map when your integration starts and keep it. Organizations are not created or changed through the API.

Node.js:

```js
const { body } = await api(token, 'GET', '/organization?limit=100');
const orgId = Object.fromEntries(body.data.map((o) => [o.slug, o._id]));
// orgId.stem === '64f0a1b2c3d4e5f6a7b8c9d1'
await api(token, 'GET', `/${orgId.stem}/exam`);
```

PHP:

```php
$result = api($token, 'GET', '/organization?limit=100');
$orgId = array_column($result['body']['data'], '_id', 'slug');
// $orgId['stem'] === '64f0a1b2c3d4e5f6a7b8c9d1'
api($token, 'GET', "/{$orgId['stem']}/exam");
```

(`api()` is the helper from [Requests and responses](https://hub.main-team.org/api/requests-and-responses#putting-it-together-a-small-request-helper).)

## Students: one student id, several records

### The student id

When you register a student, the response's `data._id` is **the student id**. Store it; it is the key for that student in your system. The response below is shortened to a few of the student's fields:

```json
{
  "success": true,
  "message": "User registered successfully.",
  "data": {
    "_id": "652f1c9b8e4b2a0012a3c4d5",
    "username": "XXK1042",
    "firstName": "Jane",
    "lastName": "Doe",
    "fullName": "Jane Doe",
    "email": "jane.doe@example.com",
    "emailConfirmed": false,
    "createdAt": "2026-09-15T08:30:12.345Z",
    "updatedAt": "2026-09-15T08:30:12.345Z"
  }
}
```

That one id works on every route, on the core record and on every organization:

| Where | Operations |
|---|---|
| `<studentId>` on flat paths | [getStudent](https://hub.main-team.org/api/reference/get-student), [updateStudent](https://hub.main-team.org/api/reference/update-student), [setStudentPassword](https://hub.main-team.org/api/reference/set-student-password) |
| `<studentId>` on organization paths | [getOrgStudent](https://hub.main-team.org/api/reference/get-org-student), [updateOrgStudent](https://hub.main-team.org/api/reference/update-org-student), [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor), [listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams), [listStudentApplications](https://hub.main-team.org/api/reference/list-student-applications) |
| `<userId>` on organization paths (same id, different name) | [listStudentCertificates](https://hub.main-team.org/api/reference/list-student-certificates), [listStudentReports](https://hub.main-team.org/api/reference/list-student-reports) |
| `studentId` in a body | [createApplication](https://hub.main-team.org/api/reference/create-application), [createSigninLink](https://hub.main-team.org/api/reference/create-signin-link) |

Both student lists, [listStudents](https://hub.main-team.org/api/reference/list-students) and [listOrgStudents](https://hub.main-team.org/api/reference/list-org-students), return the student's core record. There, `_id` is the student id and there is no `mainId`.

### Organization copies

Each organization keeps its own copy of a student. The organization creates that copy itself the first time the student follows a [sign-in link](https://hub.main-team.org/api/guides/sign-in-links) to it. The copy has an `_id` of its own, and a `mainId` that points back at the student id.

Everything that lives inside an organization (applications, certificates, reports, payments) belongs to the copy, not to the core record. You never have to deal with that when you send a request: you always send the student id, and the API finds the right copy.

You do see copies in some **responses**:

| Where | Field | What it holds | What to use |
|---|---|---|---|
| Application lists and [getApplication](https://hub.main-team.org/api/reference/get-application) | `user._id` | The copy's id | `user.mainId`, which is the student id |
| Responses to create, move and delete an application | `user` (a plain id) | The copy's id | Look the student up by the `studentId` you sent |
| Certificates | `user` (when present) | The copy's id | You asked for the list by student id, so you already know it |
| A payment on an application | `for` | The copy's id | – |
| [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor) | `data.student._id` and `data.student.mainId` | The copy's id and the student id | `data.student.mainId` |
| [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor) | `data.supervisor._id` | The supervisor's id in that organization | `data.supervisor.username`, if you need to refer to them again |

One rule covers every person object in every response:

A person's student id is their `mainId` when the object has one, and their `_id` when it does not.

```js
const studentIdOf = (person) => person.mainId ?? person._id;
```

```php
$studentId = $person['mainId'] ?? $person['_id'];
```

A listed application, shortened to the fields that matter here:

```json
{
  "_id": "66b2c3d4e5f60718293a4b5c",
  "exam": { "_id": "64a1f0b2c9d8e7f600112233", "price": 25 },
  "user": {
    "_id": "66a0b1c2d3e4f5061728394a",
    "mainId": "652f1c9b8e4b2a0012a3c4d5",
    "firstName": "Jane",
    "lastName": "Doe"
  },
  "payment": { "_id": "66b2c3d4e5f60718293a4b5d", "status": "pending", "amount": 25 }
}
```

Match that row to your student by `user.mainId`, never by `user._id`.

### Before the first sign-in

Until a student has followed a sign-in link to an organization, that organization has no copy of them. The operations that need one tell you:

| Operation | Answer without a copy |
|---|---|
| [createApplication](https://hub.main-team.org/api/reference/create-application) | `409 conflict`: `Student has never signed in to stem, so stem holds no record for them. Generate a sign-in link first with POST /:organizationId/auth/signin.` |
| [listStudentApplications](https://hub.main-team.org/api/reference/list-student-applications), [listStudentCertificates](https://hub.main-team.org/api/reference/list-student-certificates), [listStudentReports](https://hub.main-team.org/api/reference/list-student-reports) | The same `409 conflict` |
| [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor) | `404 not_found`, `Not found!`, the one answer this operation gives to every refusal (see [Supervisors](https://hub.main-team.org/api/guides/supervisors)) |
| [listApplications](https://hub.main-team.org/api/reference/list-applications), [listExamApplications](https://hub.main-team.org/api/reference/list-exam-applications) | The student simply does not appear |
| [listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams) | Works. The picker does not need the copy |
| [createSigninLink](https://hub.main-team.org/api/reference/create-signin-link) | Works. Following the link is what creates the copy |

The message names the organization by its slug, not its id. Slugs also appear in a few responses, such as `organization` in the sign-in link and supervisor link responses. They are there for people to read; paths always take the `_id`.

## Reference data ids

### Countries

Take country ids from [listCountries](https://hub.main-team.org/api/reference/list-countries) and send them as `country` when you register or update a student. `country` takes an id only, not a name.

Some countries cannot be selected. They are left out of the list, [getCountry](https://hub.main-team.org/api/reference/get-country) answers `404 not_found` for them, and a student write that names one is refused with `400 bad_request`, exactly as for an id that does not exist.

An exam's `countries` are that organization's own country records. Their `_id`s are not guaranteed to match the ids from `GET /v1/country`. To compare a student's country with an exam's, compare `iso2`, not `_id`.

### Grades

Take grade ids from [listGrades](https://hub.main-team.org/api/reference/list-grades). A grade has the same `_id` in every organization, so an exam's `grades[]._id` can be compared directly with a student's `grade._id`. When registering, `grade` also accepts the grade's name (see [Students](https://hub.main-team.org/api/guides/students)).

For display, for example to grey out exams a student could never sit:

```js
const gradeFits = exam.grades.some((g) => g._id === student.grade._id);
const countryFits =
  exam.countries.length === 0 || exam.countries.some((c) => c.iso2 === student.country.iso2);
```

This is only a hint for your own screens. What a student may actually apply for is decided by the picker and by [createApplication](https://hub.main-team.org/api/reference/create-application); see [Exams](https://hub.main-team.org/api/guides/exams).

## Ids that belong to one organization

Exam, category, session, language, application, payment, certificate and report ids exist only in the organization that returned them. Always keep them together with that organization's id, and send them only on that organization's paths.

An exam id from `stem` sent on a `neo` path does not reach the stem exam. It answers `404 not_found`, `Exam not found!`, because neo has no exam with that id. The same goes for applications (`Application not found!`) and downloads (`Not found!`).

A good key for an application in your own database is the pair `(organizationId, applicationId)`.

In the picker ([listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams)), each leaf's `matchedExam._id` is the `examId` to apply with. `matchedExam.session`, `matchedExam.category` and `matchedExam.language` are plain ids, equal to the `_id` of the tree levels above that leaf.

## References: resolved on reads, plain ids on writes

**Reads resolve references into objects**, so you do not need a second call per row:

| Record | Resolved fields |
|---|---|
| Application | `exam`, `payment`, and `user` (only `_id`, `mainId`, `firstName`, `lastName`) |
| Student | `country`, `city`, `school`, `grade`, and `supervisor` and `partner` (only `_id`, `mainId`, `firstName`, `lastName`, `fullName`, `username`) |

People are deliberately thin. For a student's full profile, call [getStudent](https://hub.main-team.org/api/reference/get-student) with their student id.

**Some references stay ids on reads, on purpose:**

- `exam` on the rows of [listExamApplications](https://hub.main-team.org/api/reference/list-exam-applications). It is the exam you asked about, so resolving it would repeat the same document on every row.
- `partners[].user` on team applications. Those are co-participants, who need not be your students, and you only ever receive details of your own.
- `session`, `category` and `language` on the picker's `matchedExam` (above).

**Writes answer with the record as stored**, so references there are plain ids:

- Creating an application (including the `200` repeat), moving one and deleting one: `exam`, `user` and `payment` are ids.
- Registering a student, both student updates, setting a password and linking a supervisor: `country`, `city`, `school`, `grade` and `supervisor` are ids. Read the student again with [getStudent](https://hub.main-team.org/api/reference/get-student) if you need them resolved.

Code that reads both shapes:

```js
const idOf = (ref) => (typeof ref === 'string' ? ref : ref?._id);
const examId = idOf(application.exam);
```

```php
$idOf = fn ($ref) => is_array($ref) ? ($ref['_id'] ?? null) : $ref;
$examId = $idOf($application['exam']);
```

## Certificates and reports: `_id` and `shortId`

Every certificate and report has an `_id` and a `shortId`: 10 capital letters and digits, such as `K7Q2M9X4TD`. The download operations accept either one:

```bash
curl -sS -o report.pdf -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" \
  "https://api.main-team.org/v1/<organizationId>/report/download/K7Q2M9X4TD"
```

A value that is not a 24-character id is looked up as a `shortId`. So a mistyped id answers `404 not_found`, `Not found!`, not `400`. The `shortId` lookup is case-sensitive: send it in capitals, exactly as it was issued. Use `_id` as the key in your own records. Keep `shortId` for when a person has to read or type the code.

## Usernames

Every student is given a username at registration, such as `XXK1042`: the country's two-letter code (written `XX` in these examples), a letter, and a number of at least four digits. The letter is never `P`, `S` or `T`; those are kept for other kinds of account. The username is read-only: no operation accepts it in a body.

You never use a student's username to address them; ids do that. The one operation that takes a username is [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor), and it takes the **supervisor's** username (for example `XXT1003`), because that is how a supervisor is identified to you. See [Supervisors](https://hub.main-team.org/api/guides/supervisors).

Do not use usernames as keys in your own database. Use the student id.

## Ids in error messages

One error message is designed to carry an id. When you register a student with an email address that is already registered to your account, the `409 conflict` names the existing student:

```json
{
  "error": {
    "code": "conflict",
    "message": "A student with that email is already registered to this account (652f1c9b8e4b2a0012a3c4d5).",
    "documentation_url": "https://hub.main-team.org/api/errors#conflict",
    "request_id": "2c1d0e9f-8a7b-4c6d-9e5f-a4b3c2d1e0f9"
  }
}
```

Fetch or update that student instead of retrying. If the same address is registered to a **different** account, the `409` carries no id: `That email address is already registered.` See [Students](https://hub.main-team.org/api/guides/students).

If you want the id programmatically, extract it defensively and fall back to searching your own records or [listStudents](https://hub.main-team.org/api/reference/list-students):

```js
const existingId = /\(([0-9a-f]{24})\)/.exec(err.message)?.[1] ?? null;
```

## When an id is wrong

What each operation answers for an id it cannot use. "Unknown" means a well-formed id that matches nothing you can see: missing, another account's, or not reachable from this organization.

| Operation | Unknown id | Malformed id |
|---|---|---|
| Any organization path, bad `<organizationId>` | `404`, `Organization not found!` | `404`, `Organization not found!` |
| [getStudent](https://hub.main-team.org/api/reference/get-student), [getOrgStudent](https://hub.main-team.org/api/reference/get-org-student), [updateStudent](https://hub.main-team.org/api/reference/update-student), [setStudentPassword](https://hub.main-team.org/api/reference/set-student-password), [updateOrgStudent](https://hub.main-team.org/api/reference/update-org-student) | `404`, `Student not found!` | `400` |
| [getCountry](https://hub.main-team.org/api/reference/get-country), [getGrade](https://hub.main-team.org/api/reference/get-grade), [getOrganization](https://hub.main-team.org/api/reference/get-organization), [getExamCategory](https://hub.main-team.org/api/reference/get-exam-category) | `404`: `Country not found!`, `Grade not found!`, `Organization not found!` or `Category not found!` | `400` |
| [linkStudentSupervisor](https://hub.main-team.org/api/reference/link-student-supervisor) | `404`, `Not found!` | `400` |
| [listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams), [listStudentApplications](https://hub.main-team.org/api/reference/list-student-applications), [listStudentCertificates](https://hub.main-team.org/api/reference/list-student-certificates), [listStudentReports](https://hub.main-team.org/api/reference/list-student-reports) | `404`, `Student not found!` | `400` |
| [getExam](https://hub.main-team.org/api/reference/get-exam) | `404`, `Exam not found!` | `400` |
| [getApplication](https://hub.main-team.org/api/reference/get-application), [moveApplication](https://hub.main-team.org/api/reference/move-application), [deleteApplication](https://hub.main-team.org/api/reference/delete-application) | `404` | `400` |
| [listExamApplications](https://hub.main-team.org/api/reference/list-exam-applications) | `200` with an empty page | `400` |
| [downloadCertificate](https://hub.main-team.org/api/reference/download-certificate), [downloadReport](https://hub.main-team.org/api/reference/download-report) | `404`, `Not found!` | `404`, `Not found!` (looked up as a `shortId`) |
| An id field in a body (`studentId`, `examId`, `country`) | Depends on the operation; see its reference page | `400`, `<field> must be a mongodb id` |

A malformed id in a path answers `400 bad_request` with a message such as `Invalid value for '_id': expected ObjectId.`

## A checklist for storing ids

- Key students by the student id (`_id` from registration). Never by a copy's `_id`, never by username, never by email.
- Key applications, exams and downloads by the pair `(organizationId, id)`.
- Keep the organization `_id`s in configuration or a startup cache, keyed by slug.
- When you read a person out of an application or any other organization record, take `mainId`.
- Store ids as 24-character strings and compare them as strings.
