Guides
Exams and the exam picker
Each organization runs its own exams. This page covers how an exam is put together, the rule that decides whether it is open, the five read operations, and the picker. The picker is the per-student list you choose from before you create an application.
Every operation here is per organization: <organizationId> is the organization's _id from GET /v1/organization, never its slug. None of them writes anything.
How an exam is put together
| Part | What it is | Key fields |
|---|---|---|
| Category | A subject or track, such as Science or Mathematics | name, order, isActive, nonAcceptedReplacements |
| Session (a sitting) | A date on which exams are sat. One sitting usually carries exams in several categories. | date, sessionName, sessionAlias, sessionNote, startTime, tz, relatedSession |
| Language | The language a paper is sat in | name, code, order |
| Exam | One category, on one sitting, in one language | session, category, language, grades, countries, price, preventApplication |
An exam has no name of its own. Describe it by its three parts, for example "Science, 14 November 2026, English". Together, the three identify the exam.
The exam fields that matter to an integration:
| Field | Meaning |
|---|---|
grades | The grades that may sit this exam. A student whose grade isn't listed can't be entered for it. An exam with an empty list is offered to nobody. |
countries | The countries the exam is restricted to. An empty list, or no list at all, means every country. |
price | What the exam costs, as a number. It is absent on an exam nobody priced, and an application for such an exam is free. Read it as price ?? 0; never assume the field is present. |
preventApplication | true closes the exam to new applications. |
duration, examTime, examType, maxPartners | How the paper itself runs. None of them affects whether the exam is open. |
A session whose relatedSession is set is a make-up sitting linked to a main one. Applications for make-up sittings are temporary; see Applications.
What "open" means
An exam is open when all three of these hold:
- its
preventApplicationis nottrue; - its sitting's
dateis still in the future; - its category is active (
isActive: true).
An exam whose sitting or category can't be found is never open.
The exam list, the single exam read, the picker and both application writes all use this same rule. That's deliberate: the API has no way to reach a closed exam. You can't list it, read it by id, apply to it, or move an application onto it.
Only the sitting's date decides whether an exam is open. startTime, tz, duration and examTime never do. An exam stays open until the moment in date.
Note
The student panel stops offering a sitting one hour before its date. The API keeps offering it until the date itself, so that the list and the application write always agree. If you show exams to people who then have to act on them, add your own buffer, for example by hiding sittings less than a day away.
Operations
| Operation | Request | Permission | Paginated |
|---|---|---|---|
| List exam categories | GET /v1/<organizationId>/exam-category | exam-category/read | yes |
| Get an exam category | GET /v1/<organizationId>/exam-category/<categoryId> | exam-category/read | no |
| List open exams | GET /v1/<organizationId>/exam | exam/read | yes |
| List exams for a student (the picker) | GET /v1/<organizationId>/exam/available/<studentId> | exam/read | no |
| Get an exam | GET /v1/<organizationId>/exam/<examId> | exam/read | no |
Every permission's target is the organization in the path, or *. Paginated routes take page (default 1) and limit (default 20, at most 100). A missing or non-numeric value falls back to the default, and an out-of-range one is clamped; neither is refused. See Pagination.
Categories
GET /v1/<organizationId>/exam-category returns every category the organization has, retired ones included. The order isn't guaranteed. Check isActive before you show a category to anyone, because only active categories have open exams.
curl -s "https://api.main-team.org/v1/<organizationId>/exam-category?limit=50" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Categories fetched successfully.",
"data": [
{
"_id": "64b7e1f0a1b2c3d4e5f60711",
"name": "Science",
"altName": "Science Olympiad",
"order": 1,
"isActive": true,
"hasCode": false,
"nonAcceptedReplacements": ["64b7e1f0a1b2c3d4e5f60799"],
"createdAt": "2025-08-01T10:00:00.000Z",
"updatedAt": "2026-06-12T08:30:00.000Z"
},
{
"_id": "64b7e1f0a1b2c3d4e5f60799",
"name": "Art",
"order": 9,
"isActive": false,
"hasCode": false,
"nonAcceptedReplacements": [],
"createdAt": "2024-08-01T10:00:00.000Z",
"updatedAt": "2025-09-30T12:00:00.000Z"
}
],
"pagination": { "page": 1, "limit": 50, "total": 2, "totalPages": 1 }
}
The examples on this page are trimmed. The API returns every stored field of a category, sitting, language or exam, so expect more fields than shown here and ignore the ones you don't use.
| Category field | Meaning |
|---|---|
name | The category's display name. Error messages quote it. |
order | Sort key. The picker lists categories by order, then by name. |
isActive | false means retired. A retired category's exams are never open, even if their sitting is still ahead. |
nonAcceptedReplacements | Ids of categories that an application in this category may not be moved to. See Moving an application. |
GET /v1/<organizationId>/exam-category/<categoryId> returns one category in the same shape.
- A malformed id (not 24 hexadecimal characters) answers
400 bad_request,Invalid value for '_id': expected ObjectId. - A well-formed id that matches no category in this organization answers
404 not_found,Category not found!.
List open exams
GET /v1/<organizationId>/exam lists the organization's open exams, soonest sitting first. Exams on the same sitting are ordered by _id, so page boundaries stay stable between requests.
curl -s "https://api.main-team.org/v1/<organizationId>/exam?page=1&limit=20" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Exams fetched successfully.",
"data": [
{
"_id": "66f1a2b3c4d5e6f708192a3b",
"session": {
"_id": "66e0c1d2e3f4a5b6c7d8e9f0",
"sessionName": "November 2026",
"date": "2026-11-14T09:00:00.000Z",
"startTime": "12:00",
"tz": "global",
"sessionAlias": "Autumn sitting",
"sessionNote": "Online. A webcam is required."
},
"category": { "_id": "64b7e1f0a1b2c3d4e5f60711", "name": "Science", "order": 1, "isActive": true },
"language": { "_id": "64a1f0b2c9d8e7f600118899", "name": "English", "code": "en", "order": 1 },
"grades": [
{ "_id": "630e01826836e67ec53dc7a6", "name": "9" },
{ "_id": "630e01826836e67ec53dc7a7", "name": "10" }
],
"countries": [],
"price": 25,
"duration": 15,
"examTime": 75,
"examType": "standard",
"maxPartners": 1,
"preventApplication": false,
"createdAt": "2026-05-02T09:12:44.000Z",
"updatedAt": "2026-08-20T14:03:10.000Z"
}
],
"pagination": { "page": 1, "limit": 20, "total": 37, "totalPages": 2 }
}
Every reference comes back as a document, not an id: session, category, language, grades and countries. You can show an exam without any further calls.
Two things to know about this list:
- It isn't a catalog. Closed, past and retired exams never appear, so an exam you saw last month can be missing today. The single read below follows the same rule.
- It knows nothing about any student. It doesn't filter by grade or country, and it includes exams with no language (
"language": null), which nobody can apply to. To choose an exam for a particular student, use the picker.
Get one exam
GET /v1/<organizationId>/exam/<examId> returns one open exam, in the same shape as a list item.
| Situation | Answer |
|---|---|
| The exam exists and is open | 200, Exam fetched successfully. |
| No exam has this id | 404 not_found, Exam not found! |
| The exam exists but is closed | 404 not_found, Exam is not open for application, so it is not available through this API. |
| The id isn't 24 hexadecimal characters | 400 bad_request |
The two 404s differ only in their message. Both mean the same thing for your integration: this exam can't be applied to now.
The per-student picker
GET /v1/<organizationId>/exam/available/<studentId> answers one question: which exams could this student be entered for right now? It returns the answer as a tree, grouped the way the student panel offers exams: by category, then sitting, then language. Every leaf carries the exam to post when you create an application.
<studentId> is the student's core id, the _id that registration returned.
What the picker filters by
An exam is offered to the student only if every rule below holds. Creating and moving an application apply exactly the same rules. So a leaf you just fetched is accepted, unless something changed in the meantime: the sitting passed, the student's grade changed, or another application was made.
| Rule | Detail |
|---|---|
| The exam is open | The three conditions in What "open" means. |
The student's grade is in the exam's grades | A hard filter. An exam with no grades is offered to nobody. |
| The exam is available in the student's country | The exam lists the student's country, or lists no countries. The student's country is matched by name within the organization. If it can't be matched there, only exams open to every country are offered, so the picker may hide an exam but never offers one the student can't sit. |
| The exam has a language | An exam with no language can't be picked, so it is never offered. |
| The student doesn't already hold that category on that sitting | If the student has an application in, say, Science on the November sitting, no Science exam on that sitting is offered, in any language (the one they hold included). To switch languages, move the application instead. |
The student doesn't need to have signed in to the organization yet. A student who never has holds no applications there, so nothing is excluded for them. Creating an application is different: it does need that first sign-in.
Refusals
| Situation | Answer |
|---|---|
| The student isn't yours, or doesn't exist | 404 not_found, Student not found!. The two cases look the same on purpose. |
| The student has no grade | 400 bad_request, Student has no grade set, and every exam is restricted to a set of grades. Set one with PUT /:organizationId/student/:studentId before listing exams. |
<studentId> isn't 24 hexadecimal characters | 400 bad_request |
A student with nothing to choose from gets 200 and "data": [], not an error.
The tree
data[] one node per category
└─ sessions[] the sittings of that category
└─ languages[] the languages that sitting can be sat in (the leaves)
└─ matchedExam the one exam this position identifies
- Every level is a whole document (a category, a sitting or a language), plus an array of the level below it. Each level's
_idis a string. - Nothing is empty. A sitting appears only if at least one language is offered under it, and a category only if at least one sitting is.
- It is sorted. Categories by
order, thenname. Sittings soonest first. Languages byorder, thenname. - The same sitting can appear under several categories, once under each category that runs an exam on it. Each copy is its own node.
- It isn't paginated. One student's options are a handful of categories, sittings and languages, and the whole tree comes back at once.
matchedExam is the exam at that position. Its session, category and language are ids, because the documents they point to are the levels the leaf already hangs under. Its grades and countries are ids too. price is absent when the exam has no price, exactly as on the exam list.
A full example
curl -s "https://api.main-team.org/v1/<organizationId>/exam/available/652f1c9b8e4b2a0012a3c4d5" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Available exams fetched successfully.",
"data": [
{
"_id": "64b7e1f0a1b2c3d4e5f60711",
"name": "Science",
"altName": "Science Olympiad",
"order": 1,
"isActive": true,
"nonAcceptedReplacements": ["64b7e1f0a1b2c3d4e5f60799"],
"sessions": [
{
"_id": "66e0c1d2e3f4a5b6c7d8e9f0",
"sessionName": "November 2026",
"date": "2026-11-14T09:00:00.000Z",
"startTime": "12:00",
"tz": "global",
"sessionAlias": "Autumn sitting",
"sessionNote": "Online. A webcam is required.",
"languages": [
{
"_id": "64a1f0b2c9d8e7f600118899",
"name": "English",
"code": "en",
"order": 1,
"matchedExam": {
"_id": "66f1a2b3c4d5e6f708192a3b",
"session": "66e0c1d2e3f4a5b6c7d8e9f0",
"category": "64b7e1f0a1b2c3d4e5f60711",
"language": "64a1f0b2c9d8e7f600118899",
"grades": ["630e01826836e67ec53dc7a6", "630e01826836e67ec53dc7a7"],
"countries": [],
"price": 25,
"duration": 15,
"examTime": 75,
"examType": "standard",
"maxPartners": 1,
"preventApplication": false
}
},
{
"_id": "64a1f0b2c9d8e7f60011889a",
"name": "German",
"code": "de",
"order": 2,
"matchedExam": {
"_id": "66f1a2b3c4d5e6f708192a3c",
"session": "66e0c1d2e3f4a5b6c7d8e9f0",
"category": "64b7e1f0a1b2c3d4e5f60711",
"language": "64a1f0b2c9d8e7f60011889a",
"grades": ["630e01826836e67ec53dc7a6", "630e01826836e67ec53dc7a7"],
"countries": ["5f8d0a1b2c3d4e5f6a7b8c9d", "5f8d0a1b2c3d4e5f6a7b8c9e"],
"price": 25,
"duration": 15,
"examTime": 75,
"examType": "standard",
"maxPartners": 1,
"preventApplication": false
}
}
]
},
{
"_id": "66e0c1d2e3f4a5b6c7d8e9f7",
"sessionName": "March 2027",
"date": "2027-03-20T09:00:00.000Z",
"startTime": "12:00",
"tz": "global",
"languages": [
{
"_id": "64a1f0b2c9d8e7f600118899",
"name": "English",
"code": "en",
"order": 1,
"matchedExam": {
"_id": "66f1a2b3c4d5e6f708192a40",
"session": "66e0c1d2e3f4a5b6c7d8e9f7",
"category": "64b7e1f0a1b2c3d4e5f60711",
"language": "64a1f0b2c9d8e7f600118899",
"grades": ["630e01826836e67ec53dc7a7"],
"countries": [],
"price": 30,
"duration": 15,
"examTime": 75,
"examType": "standard",
"maxPartners": 1,
"preventApplication": false
}
}
]
}
]
},
{
"_id": "64b7e1f0a1b2c3d4e5f60712",
"name": "Mathematics",
"order": 2,
"isActive": true,
"nonAcceptedReplacements": [],
"sessions": [
{
"_id": "66e0c1d2e3f4a5b6c7d8e9f0",
"sessionName": "November 2026",
"date": "2026-11-14T09:00:00.000Z",
"startTime": "12:00",
"tz": "global",
"languages": [
{
"_id": "64a1f0b2c9d8e7f600118899",
"name": "English",
"code": "en",
"order": 1,
"matchedExam": {
"_id": "66f1a2b3c4d5e6f708192a41",
"session": "66e0c1d2e3f4a5b6c7d8e9f0",
"category": "64b7e1f0a1b2c3d4e5f60712",
"language": "64a1f0b2c9d8e7f600118899",
"grades": ["630e01826836e67ec53dc7a6"],
"countries": [],
"duration": 15,
"examTime": 75,
"examType": "standard",
"maxPartners": 1,
"preventApplication": false
}
}
]
}
]
}
]
}
Reading it: the student can sit Science in November in English or German, or in March in English. They can also sit Mathematics in November in English, and that exam has no price, so it is free. The November sitting appears twice, once under each category. The German Science exam is restricted to two countries, and the student's country is one of them. Otherwise it wouldn't be in the tree.
Choosing a leaf
- Choose a category, then a sitting under it, then a language under that sitting.
- Take that leaf's
matchedExam._id. - Send it as
examId, with the samestudentId, toPOST /v1/<organizationId>/application.
Nothing needs to be looked up in between. If you show the choices as one flat list, flatten the tree like this:
const res = await fetch(
`https://api.main-team.org/v1/${organizationId}/exam/available/${studentId}`,
{ headers: { Authorization: `Bearer ${await getToken()}` } },
);
const { data: tree } = await res.json();
const options = [];
for (const category of tree) {
for (const session of category.sessions) {
for (const language of session.languages) {
options.push({
examId: language.matchedExam._id,
label: `${category.name} · ${session.date.slice(0, 10)} · ${language.name}`,
price: language.matchedExam.price ?? 0,
});
}
}
}
// [{ examId: '66f1a2b3c4d5e6f708192a3b', label: 'Science · 2026-11-14 · English', price: 25 }, …]
<?php
$ch = curl_init("https://api.main-team.org/v1/{$organizationId}/exam/available/{$studentId}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getToken()],
]);
$tree = json_decode(curl_exec($ch), true)['data'];
curl_close($ch);
$options = [];
foreach ($tree as $category) {
foreach ($category['sessions'] as $session) {
foreach ($session['languages'] as $language) {
$options[] = [
'examId' => $language['matchedExam']['_id'],
'label' => sprintf('%s · %s · %s', $category['name'], substr($session['date'], 0, 10), $language['name']),
'price' => $language['matchedExam']['price'] ?? 0,
];
}
}
}
Fetch the tree right before you offer choices, and don't cache it for long. It changes as sittings pass and as the student's applications change.
When to use which exam list
| You want to… | Use |
|---|---|
| Choose an exam for one student | The picker |
| Show everything the organization currently has open | List open exams |
| Show one exam you already have the id of | Get an exam |
| Look up an exam an application points to, even a past one | The application itself: its exam comes back as a document. See Applications. |
| Show category names and their order | List exam categories |
Caching
| Data | How long it stays useful |
|---|---|
| Categories | Changes rarely. Caching for a day is reasonable. |
| Open exams | Changes as sittings pass and as the organization opens or closes exams. Refresh at least hourly. |
| The picker | Changes with time and with the student's own applications. Fetch it every time you offer choices. |
Every call counts against the rate limit of its route: 100 requests per 60 seconds per API account. See Rate limits.
Related
- Applications: create, move and delete
- Students: setting a student's grade and country
- Reference data: grades and countries
- Change an application
- Error codes:
bad_request,not_found