Skip to content
API documentation
View as MarkdownOpen in Claude

Concepts

Pagination

Every list operation returns its results one page at a time. You choose the page with page and its size with limit, and every list response tells you how many records and pages there are in total. This page explains the rules, shows loops that fetch everything, and covers what to watch for when the data changes while you page.

Which operations are paginated

OperationPathOrder of results
listCountriesGET /v1/countryNo fixed order
listGradesGET /v1/gradeThe platform's own grade order (oldest record first), not alphabetical
listOrganizationsGET /v1/organizationNo fixed order
listStudentsGET /v1/studentNo fixed order
listOrgStudentsGET /v1/<organizationId>/studentNo fixed order
listExamCategoriesGET /v1/<organizationId>/exam-categoryNo fixed order
listExamsGET /v1/<organizationId>/examSoonest sitting first, then by _id
listApplicationsGET /v1/<organizationId>/applicationNo fixed order
listExamApplicationsGET /v1/<organizationId>/application/exam-applications/<examId>No fixed order
listStudentApplicationsGET /v1/<organizationId>/application/student-applications/<studentId>No fixed order
listStudentCertificatesGET /v1/<organizationId>/certificate/<userId>No fixed order
listStudentReportsGET /v1/<organizationId>/report/<userId>No fixed order

"No fixed order" means exactly that: do not rely on a record's position, and do not assume the newest record comes first or last. Identify records by _id.

Not paginated:

  • The exam picker, listAvailableExams. One student's options are a small tree (category, then sitting, then language), so it comes back whole in one response.
  • Single reads and downloads. They return one record or one file.

The two parameters

ParameterDefaultSmallestLargest
page11No upper limit. A page past the end is simply empty
limit201100
curl -sS "https://api.main-team.org/v1/student?page=2&limit=100" \
  -H "Authorization: Bearer $TOKEN"

The API is forgiving with both parameters. It never answers 400 for them. It uses the nearest value it can:

You sendThe API usesWhy
Nothingpage=1, limit=20Defaults
limit=500limit=100Capped at the largest
limit=0 or limit=-5limit=1Raised to the smallest
limit=abclimit=20Not a number, so the default
limit=10.9limit=10Whole numbers only; the fraction is dropped
limit=1e2limit=1Only the leading digits are read, so write numbers out in full
page=0page=1Raised to the smallest

Because a bad value is corrected silently, check pagination.limit in the response if you are not sure what you asked for. It always shows the values the API actually used.

The pagination object

Every list response carries a pagination object next to data:

curl -sS "https://api.main-team.org/v1/student?page=2&limit=2" \
  -H "Authorization: Bearer $TOKEN"

This response shows each student with only a few of its fields:

{
  "success": true,
  "message": "Students fetched successfully.",
  "data": [
    {
      "_id": "652f1c9b8e4b2a0012a3c4d5",
      "username": "XXK1042",
      "firstName": "Jane",
      "lastName": "Doe",
      "grade": { "_id": "5f1a2b3c4d5e6f7a8b9c0d1e", "name": "10" }
    },
    {
      "_id": "652f1c9b8e4b2a0012a3c4d6",
      "username": "XXB1043",
      "firstName": "Arben",
      "lastName": "Hoxha",
      "grade": { "_id": "5f1a2b3c4d5e6f7a8b9c0d1f", "name": "11" }
    }
  ],
  "pagination": { "page": 2, "limit": 2, "total": 7, "totalPages": 4 }
}
FieldMeaning
pageThe page you received
limitThe page size the API used, after any correction
totalHow many records match in total, across all pages
totalPagestotal divided by limit, rounded up

Edge cases:

  • Nothing matches. data is [], total is 0 and totalPages is 0.
  • The last page holds whatever is left. With 7 records and limit=2, page 4 has one record.
  • A page past the end, such as page=9 above, returns data: [] with the same total and totalPages. It is not an error.

Fetching every page

Use limit=100 whenever you want everything: it takes the fewest requests. Keep asking for the next page until you have received page totalPages, and stop early if a page comes back empty.

With curl and jq, writing one student per line:

page=1
while : ; do
  resp=$(curl -sS --fail-with-body \
    -H "Authorization: Bearer $TOKEN" \
    "https://api.main-team.org/v1/student?page=$page&limit=100") || { echo "$resp" >&2; exit 1; }

  echo "$resp" | jq -c '.data[]' >> students.ndjson

  total_pages=$(echo "$resp" | jq '.pagination.totalPages')
  count=$(echo "$resp" | jq '.data | length')
  if [ "$count" -eq 0 ] || [ "$page" -ge "$total_pages" ]; then break; fi
  page=$((page + 1))
done

Node.js (18 or later). An async generator that yields one record at a time and pauses when the operation's rate limit budget runs out:

const BASE = process.env.MTO_API_BASE ?? 'https://api.main-team.org/v1';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function* everyRecord(token, path) {
  for (let page = 1; ; page += 1) {
    const sep = path.includes('?') ? '&' : '?';
    const res = await fetch(`${BASE}${path}${sep}page=${page}&limit=100`, {
      headers: { Authorization: `Bearer ${token}` },
    });

    if (res.status === 429) {
      await sleep(Number(res.headers.get('retry-after') ?? 60) * 1000);
      page -= 1; // ask for the same page again
      continue;
    }
    const body = await res.json();
    if (!res.ok) {
      throw new Error(`${res.status} ${body.error?.code}: ${body.error?.message} (${body.error?.request_id})`);
    }

    yield* body.data;

    const { totalPages } = body.pagination;
    if (body.data.length === 0 || page >= totalPages) return;

    // Out of budget for this operation: wait for the window to reset.
    if (res.headers.get('x-ratelimit-remaining') === '0') {
      await sleep(Number(res.headers.get('x-ratelimit-reset') ?? 60) * 1000);
    }
  }
}

// Usage: every student, then every application on one organization.
for await (const student of everyRecord(token, '/student')) {
  console.log(student._id, student.username);
}
for await (const application of everyRecord(token, `/${organizationId}/application`)) {
  console.log(application._id, application.user?.mainId);
}

PHP (8.1 or later). A generator with the same behavior:

<?php
function everyRecord(string $token, string $path): Generator
{
    $base = getenv('MTO_API_BASE') ?: 'https://api.main-team.org/v1';
    $page = 1;

    while (true) {
        $headers = [];
        $sep = str_contains($path, '?') ? '&' : '?';
        $ch = curl_init("{$base}{$path}{$sep}page={$page}&limit=100");
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if ($status === 429) {
            sleep((int) ($headers['retry-after'] ?? 60));
            continue; // same page again
        }
        $body = json_decode((string) $raw, true);
        if ($status >= 400) {
            $e = $body['error'] ?? [];
            throw new RuntimeException("{$status} " . ($e['code'] ?? '') . ': ' . ($e['message'] ?? '') . ' (' . ($e['request_id'] ?? '-') . ')');
        }

        yield from $body['data'];

        if (count($body['data']) === 0 || $page >= $body['pagination']['totalPages']) {
            return;
        }
        if (($headers['x-ratelimit-remaining'] ?? null) === '0') {
            sleep((int) ($headers['x-ratelimit-reset'] ?? 60));
        }
        $page++;
    }
}

// Usage
foreach (everyRecord($token, '/student') as $student) {
    echo $student['_id'], ' ', $student['username'], PHP_EOL;
}

When the data changes while you page

Pages are counted from the start of the list each time you ask: page 3 with limit=100 means "skip 200 records, then take 100". The API keeps no cursor between your requests. If records are added or removed while you are paging, the rest of the list shifts:

  • A record removed from an earlier page moves every later record up by one. The first record of your next page was on the page you already read, so you skip one record.
  • A record added to an earlier page moves every later record down by one. You see one record twice.

How to cope:

  • Deduplicate by _id. Always, on every full walk.
  • Walk quickly. The shorter the walk, the less can change during it. Fetch pages one after another, not spread over an hour.
  • Do not delete on absence. If a record you hold did not appear in a walk, fetch it by id before you conclude it is gone. Paging may have skipped it.
  • Re-walk for completeness. For a nightly sync, a second walk that finds nothing new is good evidence you have everything.
  • total is a snapshot. It was counted when the page was served, and the next page can have a different total. Do not stop just because you have collected total records; stop on the page rules above.

The exam list changes by itself over time. listExams returns only exams still open for application, so an exam drops out as its sitting approaches or when applications close. That is also why its order (soonest sitting first) can move records forward between two walks.

Totals count only what you can see

total counts the records you are allowed to see, never the whole platform:

ListCounts
listStudentsYour own students
listOrgStudentsYour own students who have access to that organization
listApplications, listExamApplicationsApplications of your own students who have access to that organization and have signed in to it
listStudentCertificates, listStudentReportsThat student's released documents only (see Certificates and reports)
listCountriesCountries that can be selected; some cannot, and are left out
listExamsExams open for application now

Budgeting pages against the rate limit

Each operation allows your account 100 requests per 60 seconds, counted on its own (see Rate limits). At limit=100, one list operation can therefore deliver up to 10,000 records a minute. Some typical numbers:

TaskRequestsMinimum time
All 2,350 of your students24 pages of GET /v1/studentWell under a minute
All applications on one organization, 8,000 rows80 pagesUnder a minute
Certificates for 1,200 students on one organization1,200 calls to listStudentCertificates (one or more per student)About 12 minutes
The same for two organizations2,400 calls to the same operationAbout 24 minutes: one operation shares one budget across organizations

Per-student lists cost one request per student, whatever limit is, so plan those jobs by the number of students, not by the number of records. A different operation, such as the certificate download, has its own budget and can run alongside. Collect results builds a complete nightly job on these numbers.

Search the API documentation

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