Start here
Organizations
The platform behind this API is one core record plus five olympiads. Knowing which data lives where explains most of the API's shape: why some paths have an organization id and others do not, why a student has to sign in before you can enter them for an exam, and why an id sometimes seems to "not match".
One core record, five olympiads
| Slug | What it is | What lives there |
|---|---|---|
mto | The core record of the Main Team platform | Partner accounts, every student's core record (name, email, grade, country, password), and the shared reference data: countries, grades and the list of organizations |
stem | STEM olympiad | Its own exams, exam categories, applications, certificates and reports, and its own copy of each student who has signed in there |
hilingua | Language olympiad | The same, for this olympiad |
neo | Science olympiad | The same, for this olympiad |
gmath | Mathematics olympiad | The same, for this olympiad |
coding | Coding olympiad | The same, for this olympiad |
The name field of GET /v1/organization has each organization's official name. Use the slug in your code; it is short and stable.
A student exists once on the core record and, in addition, once in each olympiad they have signed in to (see Organization copies). Exams, applications and results always belong to one olympiad.
Two kinds of routes
The path tells you which part of the platform a route acts on.
| Kind | Path | Acts on | Examples |
|---|---|---|---|
| Flat | /v1/<resource> (no organization id) | The core record, mto | /v1/student, /v1/country, /v1/grade, /v1/organization, /v1/api-account/... |
| Per organization | /v1/{organizationId}/<resource> | The organization whose _id is in the path | /v1/{organizationId}/exam, /v1/{organizationId}/application, /v1/{organizationId}/auth/signin |
The complete list:
Flat routes (act on mto) | Per-organization routes |
|---|---|
GET /v1/api-account/validate-me, POST /v1/api-account/revoke-token | GET, PUT /v1/{organizationId}/student... and PUT .../student/{studentId}/supervisor |
GET /v1/country, GET /v1/country/{id} | POST /v1/{organizationId}/auth/signin |
GET /v1/grade, GET /v1/grade/{id} | GET /v1/{organizationId}/exam-category... |
GET /v1/organization, GET /v1/organization/{id} | GET /v1/{organizationId}/exam... |
GET, POST /v1/student, GET, PUT /v1/student/{studentId}, PUT /v1/student/{studentId}/password | GET, POST, PUT, DELETE /v1/{organizationId}/application... |
GET /v1/{organizationId}/certificate..., GET /v1/{organizationId}/report... |
This split also decides your permissions. A flat route needs a role whose target is mto or *. A per-organization route needs a role whose target is that organization's slug or *. See Permissions.
The organization id
Every per-organization path starts with {organizationId}: the _id of the organization, as returned by GET /v1/organization. It is 24 hexadecimal characters, such as 64b7f0c2a1e4d5f6a7b8c9d1.
Warning
The slug does not work in the path. /v1/stem/exam answers 404 not_found with Organization not found!, just as an id that belongs to no listed organization does. Always put the _id there.
List organizations
curl -s "https://api.main-team.org/v1/organization?limit=100" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Organizations fetched successfully.",
"data": [
{
"_id": "64b7f0c2a1e4d5f6a7b8c9d1",
"name": "STEM Olympiad",
"slug": "stem",
"logo": "https://example.org/logos/stem.png",
"desc": "International STEM olympiad.",
"defaultRedirect": "https://my.example-stem.org"
},
{
"_id": "64b7f0c2a1e4d5f6a7b8c9d2",
"name": "Hi-Lingua",
"slug": "hilingua",
"logo": "https://example.org/logos/hilingua.png",
"desc": "International language olympiad.",
"defaultRedirect": "https://my.example-hilingua.org"
}
],
"pagination": { "page": 1, "limit": 100, "total": 5, "totalPages": 1 }
}
(Example data, shortened to two of the five organizations.)
The list holds the five olympiads. mto, the core record, is not in it: every flat route acts on mto, so you never need its id.
| Field | Meaning |
|---|---|
_id | The id to put in /v1/{organizationId}/... |
slug | The short name, such as stem. Role targets use it too, and a role can name only mto, stem, hilingua, neo, gmath, coding or * |
name | Display name |
logo | Logo image URL, when the organization has one |
desc | Short description, when the organization has one |
defaultRedirect | Where that organization's student panel lives. A sign-in link lands there when you do not name a page |
Fields an organization does not have are left out of its entry, so treat every field except _id and slug as optional.
The list is paginated like every list (page defaults to 1, limit to 20, and values above 100 are lowered to 100). With limit=100 you get every organization in one request. See Pagination.
Needs organization/read on mto. Reference: listOrganizations.
Get one organization
curl -s "https://api.main-team.org/v1/organization/64b7f0c2a1e4d5f6a7b8c9d1" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Organization fetched successfully.",
"data": {
"_id": "64b7f0c2a1e4d5f6a7b8c9d1",
"name": "STEM Olympiad",
"slug": "stem",
"logo": "https://example.org/logos/stem.png",
"desc": "International STEM olympiad.",
"defaultRedirect": "https://my.example-stem.org"
}
}
An id that names none of the listed organizations, mto's included, answers 404 not_found with Organization not found!. A malformed id, such as abc, answers 400 bad_request with Invalid value for '_id': expected ObjectId.. (This route is flat, so it does not go through the organization check described below, where a malformed id answers 404 too.)
Needs organization/read on mto. Reference: getOrganization.
Keep a slug-to-id map
Organizations rarely change. Fetch the list when your integration starts, keep a map from slug to _id, and refresh it once a day, or whenever a call answers 404 with Organization not found!.
let orgIds = null; // { stem: '...', hilingua: '...', ... }
async function organizationId(slug) {
if (!orgIds) {
const { data } = await api('/organization?limit=100');
orgIds = Object.fromEntries(data.map((o) => [o.slug, o._id]));
}
const id = orgIds[slug];
if (!id) throw new Error(`Unknown organization slug: ${slug}`);
return id;
}
// GET /v1/{stem's _id}/exam
const exams = await api(`/${await organizationId('stem')}/exam`);
(api() is the helper from Authentication.)
The list is not filtered by your roles, so it may include organizations you have no role on. That is harmless: a call to one of them answers 403 forbidden. Work only with the organizations you agreed with the operator, and look them up by slug rather than by their position in the list, because the list has no guaranteed order.
How the id is checked
On a per-organization route the API checks, in this order:
| Order | Check | If it fails |
|---|---|---|
| 1 | Your token | 401 unauthorized, whatever the organization id. Someone without a valid token cannot find out which organizations exist |
| 2 | The organization id is 24 hexadecimal characters and names an organization that GET /v1/organization lists | 404 not_found, Organization not found! |
| 3 | Your roles allow this action on this organization | 403 forbidden, Insufficient role permissions |
| 4 | Your rate limit for this route | 429 too_many_requests |
| 5 | The request body and the route's own rules | 400, 404, 409 and so on, as each route documents |
So an unknown organization id answers 404 even when your roles would not have allowed the call, and the id's letter case does not matter. Requests refused at steps 1 to 3 do not count against your rate limit.
One thing happens before all of these steps: reading the request body. A body over 100 kB (413 payload_too_large), a body in an encoding the API does not read (415 unsupported_media_type), or a body that is not valid JSON (400 bad_request) is refused before the token is even looked at. See Requests and responses.
Your students
A student belongs to the partner account that registered them, through POST /v1/student. Every route that reads or changes a student, or anything reached through a student (applications, certificates, reports, sign-in links, supervisor links), only ever finds your own students.
A foreign record looks like a missing one
The API never tells you that a record exists but belongs to someone else. A student, application, certificate or report that belongs to another partner answers exactly as if it did not exist:
- Every route answers
404 not_found, with the message it gives for a missing record:Student not found!for a student,Application not found!for an application. - Some routes use one message for every refusal of their own. The supervisor link answers
Not found!whatever check failed, and the certificate and report downloads answerNot found!for a document that is missing, not yet released, not yours, or has no file.
This is deliberate. If a partner could tell "exists but not yours" from "does not exist", it could probe ids to learn about other partners' students.
A new account does not see old students
Ownership is tied to your account, not your company. If an operator issues you a replacement account (after a leaked secret, for example), the new account does not see the students the old one registered. Talk to the operator about those students before you switch.
Which students an organization route shows
The student routes under an organization, GET and PUT /v1/{organizationId}/student..., work on the same core records as the flat /v1/student routes, filtered to your students who have access to that organization:
Access is held in the student's activatedPlatformsThisSeason list. common in that list means "every organization"; otherwise the list names the slugs the student may use.
| You do | The student's access afterwards |
|---|---|
POST /v1/student without activatedPlatformsThisSeason | ["common"]: every organization. This is the default |
POST /v1/student with a list, for example ["stem"] | Only the organizations on the list |
PUT /v1/{organizationId}/student/{studentId} without activatedPlatformsThisSeason, even with an empty body {} | That organization is added, unless the student already has common |
PUT /v1/{organizationId}/student/{studentId} with a list | The organizations on your list are added. The organization in the path is added only if it is on your list |
PUT /v1/student/{studentId} with a list | The organizations on your list are added |
Note
An update only ever adds. Nothing you send removes an organization from a student's list: a value the list already holds is not added twice, and [] adds nothing. To give a student one more organization, send {} to that organization's update route, or send the slug alone. You never need to read the current list first.
A sign-in link for a student without access to the organization answers 403 forbidden with Student is not activated for organization <slug>.. The student guide covers the field in full: see Students.
Organization copies of a student
Each olympiad keeps its own copy of every student who has signed in there. That copy has its own _id in the olympiad, and it points back to the core record through the field mainId. Applications, certificates and reports belong to the olympiad's copy.
The copy is created the first time the student signs in to that olympiad. For an integration, that means the student following a sign-in link you minted with POST /v1/{organizationId}/auth/signin. No API call creates the copy directly; only a sign-in does.
Until the student has signed in to an olympiad once (the examples use stem; the message names the olympiad you called):
| You call | You get |
|---|---|
POST /v1/{organizationId}/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. |
GET /v1/{organizationId}/application/student-applications/{studentId} | The same 409 conflict |
GET /v1/{organizationId}/certificate/{userId} and GET /v1/{organizationId}/report/{userId} | The same 409 conflict |
PUT /v1/{organizationId}/student/{studentId}/supervisor | 404 not_found, Not found! (the supervisor link uses one answer for every refusal) |
GET /v1/{organizationId}/application and GET /v1/{organizationId}/application/exam-applications/{examId} | 200. The student is simply not in the results |
GET /v1/{organizationId}/exam/available/{studentId} | 200, as long as the student has a grade. The exam picker works before the first sign-in, so you can show the choices early |
The 409 comes after the ownership check, so a student who is not yours still answers 404 with Student not found!.
So the usual order for a new student on an olympiad is:
- Register the student on the core record:
POST /v1/student. - Mint a sign-in link on the olympiad and send the student to it:
POST /v1/{organizationId}/auth/signin. Their first sign-in creates the olympiad's copy. - Now enter them for exams and link supervisors on that olympiad.
The register and apply tutorial walks through this end to end, and Sign-in links covers the link itself.
Which id to use
Always identify a student by the core id: the _id that POST /v1/student returned. Every route that takes a studentId expects it, including the per-organization routes.
In responses that come from an olympiad (applications, for example), a student appears with both _id and mainId. There, _id is the olympiad's copy and mainId is the core id you know. Match on mainId. See Identifiers.
Where to go next
- The permission model and the target of a role: Permissions.
- Registering and updating students: Students.
- Countries and grades for registration: Reference data.
- Exams and what "open" means: Exams.