Skip to content
API documentation
View as MarkdownOpen in Claude

Concepts

Identifiers

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

IdWhere you get itWhere you send itValid in
Organization _idlistOrganizations<organizationId> in organization pathsEvery call
Student id (the student's _id)data._id from registerStudent, or listStudents<studentId> and <userId> in paths, studentId in bodiesEvery organization
Organization copy _idInside applications, certificates and paymentsNeverOne organization
mainIdOn organization copiesNever. It is the student id–
Country _idlistCountriescountry when registering a student, or on either student updateStudent records. Exams use each organization's own country records (see Countries)
Grade _idlistGradesgrade when registering or updating a studentEvery organization, unchanged
Exam, category, session and language _idlistExams, listAvailableExams, listExamCategoriesexamId in bodies, <examId> and <categoryId> in pathsOne organization
Application _idcreateApplication and the application lists<applicationId> in pathsOne organization
Certificate and report _id, shortIdlistStudentCertificates, listStudentReports<certificateId>, <reportId> on downloadsOne organization
UsernameEvery student recordA 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:

curl -sS "https://api.main-team.org/v1/organization" \
  -H "Authorization: Bearer $TOKEN"
{
  "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.

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

Node.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:

$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.)

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:

{
  "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:

WhereOperations
<studentId> on flat pathsgetStudent, updateStudent, setStudentPassword
<studentId> on organization pathsgetOrgStudent, updateOrgStudent, linkStudentSupervisor, listAvailableExams, listStudentApplications
<userId> on organization paths (same id, different name)listStudentCertificates, listStudentReports
studentId in a bodycreateApplication, createSigninLink

Both student lists, listStudents and listOrgStudents, 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 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:

WhereFieldWhat it holdsWhat to use
Application lists and getApplicationuser._idThe copy's iduser.mainId, which is the student id
Responses to create, move and delete an applicationuser (a plain id)The copy's idLook the student up by the studentId you sent
Certificatesuser (when present)The copy's idYou asked for the list by student id, so you already know it
A payment on an applicationforThe copy's id–
linkStudentSupervisordata.student._id and data.student.mainIdThe copy's id and the student iddata.student.mainId
linkStudentSupervisordata.supervisor._idThe supervisor's id in that organizationdata.supervisor.username, if you need to refer to them again

One rule covers every person object in every response:

Note

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

const studentIdOf = (person) => person.mainId ?? person._id;
$studentId = $person['mainId'] ?? $person['_id'];

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

{
  "_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:

OperationAnswer without a copy
createApplication409 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, listStudentCertificates, listStudentReportsThe same 409 conflict
linkStudentSupervisor404 not_found, Not found!, the one answer this operation gives to every refusal (see Supervisors)
listApplications, listExamApplicationsThe student simply does not appear
listAvailableExamsWorks. The picker does not need the copy
createSigninLinkWorks. 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 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 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 _ids 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. 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).

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

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; see 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), 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:

RecordResolved fields
Applicationexam, payment, and user (only _id, mainId, firstName, lastName)
Studentcountry, 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 with their student id.

Some references stay ids on reads, on purpose:

  • exam on the rows of listExamApplications. 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 if you need them resolved.

Code that reads both shapes:

const idOf = (ref) => (typeof ref === 'string' ? ref : ref?._id);
const examId = idOf(application.exam);
$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:

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, and it takes the supervisor's username (for example XXT1003), because that is how a supervisor is identified to you. See 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:

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

If you want the id programmatically, extract it defensively and fall back to searching your own records or listStudents:

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.

OperationUnknown idMalformed id
Any organization path, bad <organizationId>404, Organization not found!404, Organization not found!
getStudent, getOrgStudent, updateStudent, setStudentPassword, updateOrgStudent404, Student not found!400
getCountry, getGrade, getOrganization, getExamCategory404: Country not found!, Grade not found!, Organization not found! or Category not found!400
linkStudentSupervisor404, Not found!400
listAvailableExams, listStudentApplications, listStudentCertificates, listStudentReports404, Student not found!400
getExam404, Exam not found!400
getApplication, moveApplication, deleteApplication404400
listExamApplications200 with an empty page400
downloadCertificate, downloadReport404, 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 page400, <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 _ids 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.

Search the API documentation

Guides, endpoints by name, path or permission, and error codes such as not_found.