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
| Operation | Path | Order of results |
|---|---|---|
| listCountries | GET /v1/country | No fixed order |
| listGrades | GET /v1/grade | The platform's own grade order (oldest record first), not alphabetical |
| listOrganizations | GET /v1/organization | No fixed order |
| listStudents | GET /v1/student | No fixed order |
| listOrgStudents | GET /v1/<organizationId>/student | No fixed order |
| listExamCategories | GET /v1/<organizationId>/exam-category | No fixed order |
| listExams | GET /v1/<organizationId>/exam | Soonest sitting first, then by _id |
| listApplications | GET /v1/<organizationId>/application | No fixed order |
| listExamApplications | GET /v1/<organizationId>/application/exam-applications/<examId> | No fixed order |
| listStudentApplications | GET /v1/<organizationId>/application/student-applications/<studentId> | No fixed order |
| listStudentCertificates | GET /v1/<organizationId>/certificate/<userId> | No fixed order |
| listStudentReports | GET /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
| Parameter | Default | Smallest | Largest |
|---|---|---|---|
page | 1 | 1 | No upper limit. A page past the end is simply empty |
limit | 20 | 1 | 100 |
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 send | The API uses | Why |
|---|---|---|
| Nothing | page=1, limit=20 | Defaults |
limit=500 | limit=100 | Capped at the largest |
limit=0 or limit=-5 | limit=1 | Raised to the smallest |
limit=abc | limit=20 | Not a number, so the default |
limit=10.9 | limit=10 | Whole numbers only; the fraction is dropped |
limit=1e2 | limit=1 | Only the leading digits are read, so write numbers out in full |
page=0 | page=1 | Raised 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 }
}
| Field | Meaning |
|---|---|
page | The page you received |
limit | The page size the API used, after any correction |
total | How many records match in total, across all pages |
totalPages | total divided by limit, rounded up |
Edge cases:
- Nothing matches.
datais[],totalis0andtotalPagesis0. - 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=9above, returnsdata: []with the sametotalandtotalPages. 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.
totalis 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 collectedtotalrecords; 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:
| List | Counts |
|---|---|
| listStudents | Your own students |
| listOrgStudents | Your own students who have access to that organization |
| listApplications, listExamApplications | Applications of your own students who have access to that organization and have signed in to it |
| listStudentCertificates, listStudentReports | That student's released documents only (see Certificates and reports) |
| listCountries | Countries that can be selected; some cannot, and are left out |
| listExams | Exams 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:
| Task | Requests | Minimum time |
|---|---|---|
| All 2,350 of your students | 24 pages of GET /v1/student | Well under a minute |
| All applications on one organization, 8,000 rows | 80 pages | Under a minute |
| Certificates for 1,200 students on one organization | 1,200 calls to listStudentCertificates (one or more per student) | About 12 minutes |
| The same for two organizations | 2,400 calls to the same operation | About 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.