Skip to content
API documentation
View as MarkdownOpen in Claude

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

SlugWhat it isWhat lives there
mtoThe core record of the Main Team platformPartner accounts, every student's core record (name, email, grade, country, password), and the shared reference data: countries, grades and the list of organizations
stemSTEM olympiadIts own exams, exam categories, applications, certificates and reports, and its own copy of each student who has signed in there
hilinguaLanguage olympiadThe same, for this olympiad
neoScience olympiadThe same, for this olympiad
gmathMathematics olympiadThe same, for this olympiad
codingCoding olympiadThe 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.

KindPathActs onExamples
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-tokenGET, 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}/passwordGET, 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.

FieldMeaning
_idThe id to put in /v1/{organizationId}/...
slugThe short name, such as stem. Role targets use it too, and a role can name only mto, stem, hilingua, neo, gmath, coding or *
nameDisplay name
logoLogo image URL, when the organization has one
descShort description, when the organization has one
defaultRedirectWhere 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:

OrderCheckIf it fails
1Your token401 unauthorized, whatever the organization id. Someone without a valid token cannot find out which organizations exist
2The organization id is 24 hexadecimal characters and names an organization that GET /v1/organization lists404 not_found, Organization not found!
3Your roles allow this action on this organization403 forbidden, Insufficient role permissions
4Your rate limit for this route429 too_many_requests
5The request body and the route's own rules400, 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 answer Not 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 doThe 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 listThe 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 listThe 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 callYou get
POST /v1/{organizationId}/application409 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}/supervisor404 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:

  1. Register the student on the core record: POST /v1/student.
  2. 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.
  3. 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.

Search the API documentation

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