Skip to content
API documentation
View as MarkdownOpen in Claude

Guides

Reference data

Three read-only directories supply the ids the rest of the API asks for:

DirectoryWhat you use it forOperations
CountriesThe country field when you register a studentList countries, Get a country
GradesThe grade field at registration, and the grade an exam is restricted toList grades, Get a grade
OrganizationsThe <organizationId> in every /v1/<organizationId>/… pathList organizations, Get an organization

The API never creates, changes or deletes reference data. Each of these routes is a plain read, and nothing you send to any other route adds a country, grade, city or school. A value that matches nothing is refused (see How reference fields resolve).

Permissions

All six routes are flat routes: they have no <organizationId> in the path, so they act on the core record (mto). A role that grants one needs target mto or *.

RoutePermission
GET /v1/country, GET /v1/country/<id>country/read
GET /v1/grade, GET /v1/grade/<id>grade/read
GET /v1/organization, GET /v1/organization/<id>organization/read

Without the permission the answer is 403 with code forbidden and the message Insufficient role permissions. The Permissions page explains how roles match.

Countries

List countries

curl "https://api.main-team.org/v1/country?page=1&limit=100" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Countries fetched successfully.",
  "data": [
    {
      "_id": "630e0182c53dc79a6836e67e",
      "name": "GERMANY",
      "iso2": "DE",
      "iso3": "DEU",
      "createdAt": "2022-08-30T12:14:26.118Z",
      "updatedAt": "2022-08-30T12:14:26.118Z"
    },
    {
      "_id": "630e0182c53dc79a6836e67f",
      "name": "ALGERIA",
      "iso2": "DZ",
      "iso3": "DZA",
      "createdAt": "2022-08-30T12:14:26.118Z",
      "updatedAt": "2022-08-30T12:14:26.118Z"
    }
  ],
  "pagination": { "page": 1, "limit": 100, "total": 246, "totalPages": 3 }
}

The list is paginated like every listing: page defaults to 1, and limit defaults to 20 with a maximum of 100. A larger limit is lowered to 100 rather than refused (see Pagination). The list has no guaranteed order, so page through all of it and index it yourself.

FieldTypeNotes
_idstring, 24 hex charactersThe value country takes at registration.
namestringStored in upper case, for example "GERMANY".
iso2stringTwo-letter code, upper case. It is also the first two characters of every username minted for a student in that country.
iso3stringThree-letter code, upper case.
tz, dialCode, flagstringPresent on some countries.
createdAt, updatedAtISO 8601 timestamp

A country document may carry other fields, such as __v. Ignore fields you do not use.

Note

Some countries cannot be selected. They are left out of the list and out of the total, and GET /v1/country/<id> answers for one of them exactly as it answers for an id that does not exist. A registration or update that names one is refused with 400 and country is not a known country.

Get one country

curl "https://api.main-team.org/v1/country/630e0182c53dc79a6836e67e" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Country fetched successfully.",
  "data": {
    "_id": "630e0182c53dc79a6836e67e",
    "name": "GERMANY",
    "iso2": "DE",
    "iso3": "DEU",
    "createdAt": "2022-08-30T12:14:26.118Z",
    "updatedAt": "2022-08-30T12:14:26.118Z"
  }
}

Why country takes an id and nothing else

At registration grade, city and school accept a name, but country accepts only the _id from this list. A country has three common spellings (its name, its ISO2 code and its ISO3 code), and choosing between them is ambiguity the field does not need. The country also scopes how a city or school name is looked up, so it has to be exact. Build a lookup from iso2 (or whatever code your own records use) to _id once, and translate on your side.

Grades

List grades

curl "https://api.main-team.org/v1/grade?limit=100" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Grades fetched successfully.",
  "data": [
    {
      "_id": "630e01826836e67ec53dc79d",
      "name": "1",
      "createdAt": "2022-08-30T12:14:26.301Z",
      "updatedAt": "2022-08-30T12:14:26.301Z"
    },
    {
      "_id": "630e01826836e67ec53dc79e",
      "name": "2",
      "createdAt": "2022-08-30T12:14:26.302Z",
      "updatedAt": "2022-08-30T12:14:26.302Z"
    }
  ],
  "pagination": { "page": 1, "limit": 100, "total": 12, "totalPages": 1 }
}

Grades come back in the order they were created, oldest first. They are not sorted by name: names are strings, and sorting them as text would put "10" before "2".

FieldTypeNotes
_idstring, 24 hex charactersThe same id on every organization.
namestringThe grade as a school would say it, for example "10".
createdAt, updatedAtISO 8601 timestamp

Grade ids are the same everywhere

A grade has the same _id on the core record and on every organization. That is why a student's grade can be checked directly against the grades an exam accepts, on any organization, without translation. It is also why the API is strict about the grade field: a grade that does not exist in this list is a student no exam will ever accept.

A name works too

When you register or update a student, grade takes either an _id from this list or its name. "grade": "10" is resolved to the grade whose name is 10, and that grade's _id is what gets stored, so the two forms end in the same place. A name that matches no grade is refused with 400 and grade is not a known grade. This API does not create reference data. This list shows which names are accepted.

Get one grade

curl "https://api.main-team.org/v1/grade/630e01826836e67ec53dc79d" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Grade fetched successfully.",
  "data": {
    "_id": "630e01826836e67ec53dc79d",
    "name": "1",
    "createdAt": "2022-08-30T12:14:26.301Z",
    "updatedAt": "2022-08-30T12:14:26.301Z"
  }
}

Organizations

List organizations

curl "https://api.main-team.org/v1/organization" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Organizations fetched successfully.",
  "data": [
    {
      "_id": "64b7f0c2a1e4d93b5c2f1a02",
      "name": "STEM Olympiad",
      "slug": "stem",
      "logo": "https://cdn.example.org/logos/stem.svg",
      "desc": "International STEM olympiad.",
      "defaultRedirect": "https://my.stemolympiad.org"
    },
    {
      "_id": "64b7f0c2a1e4d93b5c2f1a03",
      "name": "Hi-Lingua",
      "slug": "hilingua",
      "logo": "https://cdn.example.org/logos/hilingua.svg",
      "desc": "International language olympiad."
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 5, "totalPages": 1 }
}

The list holds the five organizations that run exams: stem, hilingua, neo, gmath and coding. mto, the core record, is not listed. The routes without an <organizationId> act on it, so you never need its id, and GET /v1/organization/<id> answers 404 for it.

An organization comes back with exactly these fields:

FieldTypeNotes
_idstring, 24 hex charactersThe <organizationId> every /v1/<organizationId>/… route takes.
namestringDisplay name.
slugstringShort name: stem, hilingua, neo, gmath or coding. Used in role targets and in a student's activatedPlatformsThisSeason, never in a path.
logostringLogo URL, when set.
descstringDescription, when set.
defaultRedirectstringWhere a sign-in link sends the student when you give no redirect, when set.

Use the _id, never the slug

/v1/stem/exam does not work. The path segment must be the organization's _id: /v1/64b7f0c2a1e4d93b5c2f1a02/exam. A segment that is not 24 hex characters, or that names no organization, is answered with 404, code not_found and the message Organization not found!. That check runs after your token is checked, so a request without a valid token gets 401 whatever the path says.

{
  "error": {
    "code": "not_found",
    "message": "Organization not found!",
    "documentation_url": "https://hub.main-team.org/api/errors#not_found",
    "request_id": "3f9d2c1e-7a4b-4e0f-9c3d-1b2a5e6f7d80"
  }
}

Organization ids do not change, so fetch the list once, keep a map from slug to _id, and use the map everywhere you build a path. Organizations explains what the core record and the five olympiads are, and why the flat routes act on mto.

Get one organization

curl "https://api.main-team.org/v1/organization/64b7f0c2a1e4d93b5c2f1a02" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Organization fetched successfully.",
  "data": {
    "_id": "64b7f0c2a1e4d93b5c2f1a02",
    "name": "STEM Olympiad",
    "slug": "stem",
    "logo": "https://cdn.example.org/logos/stem.svg",
    "desc": "International STEM olympiad.",
    "defaultRedirect": "https://my.stemolympiad.org"
  }
}

Ids that are unknown or malformed

You sendGET /v1/country/<id>GET /v1/grade/<id>GET /v1/organization/<id>
A well-formed id that matches nothing404, Country not found!404, Grade not found!404, Organization not found!
Anything that is not 24 hex characters400, Invalid value for '_id': expected ObjectId.the samethe same

A country that cannot be selected answers like an unknown id, and so does an organization the list leaves out, mto included. Every 404 here has the code not_found, and every 400 the code bad_request.

Cities and schools

There is no route that lists cities or schools. At registration you name them instead: city is looked up by name within the student's country, and school within that country and city. A name the platform does not know is refused, and the API cannot add one. If a real city or school is refused, write to info@main-team.org with the country, city and school as your records have them. Students describes the lookup rules in full.

Caching

Reference data changes rarely, and each read counts against your rate limit (100 requests per 60 seconds per operation). A sensible pattern:

  • Load all organizations, countries and grades when your integration starts, and keep them in memory or in your own store.
  • Refresh them once a day.
  • Refresh early when a student write is refused with country is not a known country. or grade is not a known grade. for an id you took from your cache. Then retry once with the fresh data.

This loader builds the three maps you need. It follows totalPages, so it works however large a list grows.

const BASE = 'https://api.main-team.org/v1';

async function getAll(path, token) {
  const items = [];
  for (let page = 1; ; page++) {
    const res = await fetch(`${BASE}${path}?page=${page}&limit=100`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
    const body = await res.json();
    items.push(...body.data);
    if (page >= body.pagination.totalPages) return items;
  }
}

export async function loadReferenceData(token) {
  const [organizations, countries, grades] = await Promise.all([
    getAll('/organization', token),
    getAll('/country', token),
    getAll('/grade', token),
  ]);
  return {
    organizationIdBySlug: new Map(organizations.map((o) => [o.slug, o._id])),
    countryIdByIso2: new Map(countries.map((c) => [c.iso2, c._id])),
    gradeIdByName: new Map(grades.map((g) => [g.name, g._id])),
  };
}

// const ref = await loadReferenceData(token);
// ref.organizationIdBySlug.get('stem')  // "64b7f0c2a1e4d93b5c2f1a02"
// ref.countryIdByIso2.get('AL')         // "630e0182c53dc79a6836e67e"
<?php
const MT_BASE = 'https://api.main-team.org/v1';

function mt_get_all(string $path, string $token): array
{
    $items = [];
    for ($page = 1; ; $page++) {
        $ch = curl_init(MT_BASE . $path . '?page=' . $page . '&limit=100');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
            CURLOPT_TIMEOUT => 30,
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        if ($raw === false || $status !== 200) {
            throw new RuntimeException("$path: HTTP $status");
        }
        $body = json_decode($raw, true);
        array_push($items, ...$body['data']);
        if ($page >= $body['pagination']['totalPages']) {
            return $items;
        }
    }
}

function mt_load_reference_data(string $token): array
{
    $organizations = mt_get_all('/organization', $token);
    $countries = mt_get_all('/country', $token);
    $grades = mt_get_all('/grade', $token);

    return [
        'organizationIdBySlug' => array_column($organizations, '_id', 'slug'),
        'countryIdByIso2' => array_column($countries, '_id', 'iso2'),
        'gradeIdByName' => array_column($grades, '_id', 'name'),
    ];
}

// $ref = mt_load_reference_data($token);
// $ref['organizationIdBySlug']['stem']; // "64b7f0c2a1e4d93b5c2f1a02"

When a list is empty, totalPages is 0. Both loops stop after the first page either way.

Search the API documentation

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