Guides
Applications
An application enters one student for one exam in one organization. It lives in that organization and always has a payment record attached. This page covers creating, moving, deleting and reading applications, with every rule the API enforces, in the order it enforces them.
Choose the exam first, from the exam picker. Every rule on this page that concerns the exam is the picker's rule, so a leaf from the picker is accepted.
Operations
| Operation | Request | Permission |
|---|---|---|
| Create an application | POST /v1/<organizationId>/application | application/create |
| Move an application | PUT /v1/<organizationId>/application/<applicationId> | application/update |
| Delete an application | DELETE /v1/<organizationId>/application/<applicationId> | application/delete |
| List applications | GET /v1/<organizationId>/application | application/read |
| List applications for an exam | GET /v1/<organizationId>/application/exam-applications/<examId> | application/read |
| List a student's applications | GET /v1/<organizationId>/application/student-applications/<studentId> | application/read |
| Get an application | GET /v1/<organizationId>/application/<applicationId> | application/read |
Every permission's target is the organization in the path, or *. Each operation is its own permission, so your operator can grant an account creation without deletion. See Permissions.
Before you create one
| Requirement | If it isn't met |
|---|---|
| The student is yours | 404 not_found, Student not found! |
| The student has signed in to this organization at least once, which creates the organization's copy of them | 409 conflict. Send them a sign-in link first. |
| The student has a grade | 400 bad_request |
| The exam is one the picker offers this student | 409 conflict, naming the rule that failed |
Create an application
POST /v1/<organizationId>/application
| Field | Type | Required | Rules |
|---|---|---|---|
studentId | string | yes | The student's core id: the _id that registration returned. 24 hexadecimal characters. |
examId | string | yes | The exam's _id in this organization: a picker leaf's matchedExam._id, or an _id from the exam list. 24 hexadecimal characters. |
Nothing else is accepted. Any other field, such as price or participated, is refused with 400 bad_request (property <name> should not exist).
curl -s -X POST "https://api.main-team.org/v1/<organizationId>/application" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "studentId": "652f1c9b8e4b2a0012a3c4d5", "examId": "66f1a2b3c4d5e6f708192a3b" }'
{
"success": true,
"message": "Application created successfully.",
"data": {
"_id": "6703d4e5f6a7b8c9d0e1f203",
"exam": "66f1a2b3c4d5e6f708192a3b",
"user": "66fa0b1c2d3e4f5a6b7c8d9e",
"payment": "6703d4e5f6a7b8c9d0e1f204",
"participated": false,
"partners": [],
"carriedPoints": 0,
"uuid": "3fa-9c0-1be",
"createdAt": "2026-09-15T10:42:07.512Z",
"updatedAt": "2026-09-15T10:42:07.512Z"
}
}
The examples on this page are trimmed: applications and payments come back with every stored field, so expect more than shown and ignore what you don't use.
A new application answers 201. In this response exam, user and payment are ids. user is the organization's id for the student, not the studentId you sent. Every organization keeps its own copy of a student under its own _id (see Identifiers). Match applications to your records by the studentId you sent, or by user.mainId when you read them back.
What is checked, in order
The first check that fails decides the answer.
| # | Check | Refusal |
|---|---|---|
| 1 | Token, organization, permission, rate limit and body, as on every route | 401, 404 (Organization not found!), 403, 429, 400 (for example examId must be a mongodb id) |
| 2 | The student is yours | 404 not_found, Student not found! |
| 3 | The organization holds a copy of the student | 409 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. |
| 4 | The exam exists in this organization | 404 not_found, Exam not found! |
| 5 | The exam is open | 409 conflict, Exam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to. |
| 6 | The student has a 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 applying. |
| 7 | The student's grade is one the exam accepts | 409 conflict, for example Exam is not available for grade 8. It accepts grade 9, 10. An exam with no grades at all ends in It accepts no grades. |
| 8 | The exam is available in the student's country | 409 conflict, for example Exam is not available in this student’s country. It is offered in GERMANY, AUSTRIA. |
| 9 | The exam has a language | 409 conflict, Exam has no language set, so it is not offered to students and cannot be applied to. |
| 10 | The student already has this exact exam | not a refusal: 200, Application already exists., with the existing application |
| 11 | The student has no other exam in the same category on the same sitting | 409 conflict, for example Student already has an application for Science on this sitting. Two exams in one category on one date cannot both be sat. |
| 12 | All checks passed | 201, Application created successfully. |
Country names in these messages are upper case, as the platform stores them. Check 8 compares the student's country by name with the countries the organization knows. If the student has no country, or the organization has no country of that name, only exams open to every country pass.
If checks 5 to 9 ever disagree with the picker, the answer is a general 409 conflict: Exam is not available to this student. Only exams returned by GET /:organizationId/exam/available/:studentId can be applied to. You should never see it.
Why the rules exist:
- Open, grade, country, language (5 to 9). These are the picker's rules. An exam the student panel would never show a student can't be reached by passing its id either. The messages name the grades or countries the exam does accept, so you can act on them without looking anything up.
- One category per sitting (11). Nobody can sit two papers in the same category at the same time, and the second application would be a payment for something that can't happen. To change the language of an exam a student already holds, move the application.
Repeating a create is safe
Sending the same studentId and examId again doesn't create a second application. It answers 200 with Application already exists. and the application that is already there. That makes create safe to retry after a timeout: a 201 or a 200 both mean the student is entered.
One caveat: the exam checks (5 to 9) run before the repeat check (10). A repeat therefore returns 200 only while the exam is still offered to the student. After the sitting's date has passed, or if the student's grade changed, the same request answers 409. The existing application is untouched either way. If a retry gives you a 409, list the student's applications to see where you stand. See also Retries and idempotency.
What a new application contains
Creating an application also creates its payment record:
Exam price | Payment created |
|---|---|
absent or 0 | amount: 0, status: "paid". A free exam needs no payment, so its record starts out settled. |
a positive number, for example 25 | amount: 25, status: "pending" |
The student can see the application in the organization's panel straight away.
An application for a make-up sitting (a sitting whose relatedSession is set) is temporary. It is created with a removeAfter time six hours ahead, just like one made in the student panel. Don't treat it as permanent.
Code: create with every outcome handled
async function enterStudent(organizationId, studentId, examId) {
const res = await fetch(`https://api.main-team.org/v1/${organizationId}/application`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await getToken()}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ studentId, examId }),
});
const body = await res.json();
if (res.status === 201) return { created: true, application: body.data };
if (res.status === 200) return { created: false, application: body.data }; // already entered
// 400, 404 and 409 are final: retrying the same request gives the same answer.
// Show error.message to whoever chose the exam, because it says which rule failed.
const { code, message, request_id } = body.error;
throw Object.assign(new Error(message), { status: res.status, code, requestId: request_id });
}
<?php
function enterStudent(string $organizationId, string $studentId, string $examId): array
{
$ch = curl_init("https://api.main-team.org/v1/{$organizationId}/application");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getToken(), 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode(['studentId' => $studentId, 'examId' => $examId]),
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status === 201) return ['created' => true, 'application' => $body['data']];
if ($status === 200) return ['created' => false, 'application' => $body['data']];
$e = $body['error'];
throw new RuntimeException("{$status} {$e['code']}: {$e['message']} (request {$e['request_id']})");
}
Payments
The API records what an application costs and whether it has been paid. It never takes, charges or refunds money. Payment happens on the platform, outside this API. That one fact explains most of the rules below.
| Payment field | Meaning |
|---|---|
status | pending, paid or canceled |
amount | What the application costs. For a settled payment, what was actually paid. |
exam, application | What the payment is for. |
for | The organization's id for the student. |
Settled means status is paid and amount is greater than 0: someone actually paid money. A free exam's { "amount": 0, "status": "paid" } isn't settled, so it never blocks a move or a delete.
| Action | Effect on the payment |
|---|---|
| Create | Created as in What a new application contains. |
| Move, payment not settled | Rewritten to the new exam's price. status becomes paid if the new price is 0, and pending otherwise. |
| Move, payment settled, same price | Only the exam it's for changes. The amount paid is left as it was. |
| Move, payment settled, different price | Refused with 409, because the API can't charge the difference or refund it. |
| Delete, not settled | Allowed. |
| Delete, settled | Refused with 409, because deleting doesn't refund anything. |
Warning
Moving a student from a free exam to a priced one leaves them owing the new price. Their payment goes from paid with amount: 0 to pending with the new amount. Tell the student or their school before you make that move.
Move an application
PUT /v1/<organizationId>/application/<applicationId> points an existing application at a different exam. Use it to change the language, the sitting or, where the category allows it, the category. The application keeps its _id and its payment record, so it works like the "Update" button on the student's own exam card.
The body has one field, examId. It must be 24 hexadecimal characters, and it is usually a picker leaf's matchedExam._id. The student isn't in the body: the application already says whose it is, so sending studentId is refused with 400 bad_request (property studentId should not exist).
curl -s -X PUT "https://api.main-team.org/v1/<organizationId>/application/6703d4e5f6a7b8c9d0e1f203" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "examId": "66f1a2b3c4d5e6f708192a3c" }'
{
"success": true,
"message": "Application updated successfully.",
"data": {
"_id": "6703d4e5f6a7b8c9d0e1f203",
"exam": "66f1a2b3c4d5e6f708192a3c",
"user": "66fa0b1c2d3e4f5a6b7c8d9e",
"payment": "6703d4e5f6a7b8c9d0e1f204",
"participated": false,
"partners": [],
"createdAt": "2026-09-15T10:42:07.512Z",
"updatedAt": "2026-09-16T08:05:51.004Z"
}
}
The application being moved doesn't count against itself. You can move it to another language on the same sitting, which is the most common move. Every other application the student holds still counts.
What is checked, in order
| # | Check | Refusal, and why |
|---|---|---|
| 1 | Token, organization, permission, rate limit and body | as on every route |
| 2 | The application exists | 404 not_found, Application not found!. An <applicationId> that isn't 24 hexadecimal characters answers 400 bad_request. |
| 3 | It belongs to one of your students | 404 not_found, Application not found!: the same answer as check 2, so a refusal never says whether the id exists |
| 4 | The exam hasn't been started (participated and examSubmitted are not true) | 409 conflict, This exam has already been started and can no longer be changed. A student's answers belong to the questions of the exam they started, and the exam's clock is read from the application's current exam. |
| 5 | The new exam exists | 404 not_found, Exam not found! |
| 6 | The new exam is the one it already has | not a refusal: 200, Application already uses that exam., with nothing changed. This makes a retried move safe. |
| 7 | The picker offers the new exam to this student | the same refusals as create checks 5 to 9: closed, no grade (400), grade, country, no language |
| 8 | The old exam's category accepts the new category | 409 conflict, for example An application for Science cannot be moved to that category. The organization lists, on each category, the categories it can't be swapped for (nonAcceptedReplacements). |
| 9 | The student has no other exam in that category on that sitting | 409 conflict, the same message as create check 11 |
| 10 | The AI Challenge rule | 409 conflict, Training has already started for this AI Challenge application, so it can no longer be moved. |
| 11 | A settled payment keeps its price | 409 conflict, for example Application has been paid for at 25, and that exam costs 40. Moving it would change what the payment bought, and this API neither charges a difference nor refunds. |
| 12 | All checks passed | 200, Application updated successfully. |
A move onto a make-up sitting makes the application temporary, exactly as creating one there does (see What a new application contains). A move off a make-up sitting makes it permanent again.
The AI Challenge rule
Students in the AI Challenge category have an image quota, which they use up as they train. Once a student has used any of their quota in an organization, none of their applications whose current exam is in the AI Challenge category can be moved there. What they used can't be carried to another exam, and moving the application would leave no record of it.
- The rule looks at the category the application is moving from. Moving an application into AI Challenge isn't affected.
- It applies whether or not the application has been paid for.
- A student who hasn't used any quota yet can be moved like anyone else.
A paid application can move
Paid applications aren't frozen. Changing the language of an exam that has already been paid for, at the same price, is an ordinary move. What the API refuses is a move that would change what the money bought (check 11). If the price differs, the student has to arrange it on the platform, or you can wait for an equal-price option.
The Change an application tutorial walks through a language swap and each 409.
Delete an application
DELETE /v1/<organizationId>/application/<applicationId> withdraws the student from the exam.
| # | Check | Refusal |
|---|---|---|
| 1 | Token, organization, permission and rate limit | as on every route |
| 2 | The application exists | 404 not_found, Application not found!. An <applicationId> that isn't 24 hexadecimal characters answers 400 bad_request. |
| 3 | It belongs to one of your students | 404 not_found, Application not found!: the same answer as check 2 |
| 4 | Its payment isn't settled | 409 conflict, Application has been paid for and cannot be deleted. Cancelling a paid application requires a refund, which this API does not perform. |
| 5 | All checks passed | 200, Application deleted successfully., with the deleted application in data |
Why a settled application can't be deleted: deleting refunds nothing. The payment would be left pointing at an application that no longer exists, and the money would have bought nothing anyone can find. Canceling a paid sitting is a refund, and refunds happen outside this API.
Deleting a second time answers 404 not_found, Application not found!. The application is already gone, so treat that as success when you retry.
Payment is the only state the delete checks. It doesn't check whether the student has started the exam, or whether the sitting is still ahead, so look at participated and examSubmitted before you delete. If your integration should never delete, ask your operator to leave application/delete off your account's roles.
Read applications
Three lists and one single read. All of them resolve references, so a row is usable on its own:
| Operation | Returns | exam | user | payment |
|---|---|---|---|---|
GET …/application | Every application of your students in this organization | document | student summary | document |
GET …/application/exam-applications/<examId> | Your students' applications for one exam | id | student summary | document |
GET …/application/student-applications/<studentId> | One student's applications | document | student summary | document |
GET …/application/<applicationId> | One application | document | student summary | document |
The student summary is deliberately short: _id, mainId, firstName and lastName. Match on mainId, which is the core id you registered the student with. _id is the organization's own id for them. For the full profile, call GET /v1/student/<mainId>.
On exam-applications/<examId> the exam stays an id because you already named it in the path, and every row would repeat the same document. On a team application, each entry in partners names its co-participant by user, and that user always stays an id. Co-participants can be other accounts' students, and the API only returns details of your own.
curl -s "https://api.main-team.org/v1/<organizationId>/application/student-applications/652f1c9b8e4b2a0012a3c4d5" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Applications fetched successfully.",
"data": [
{
"_id": "6703d4e5f6a7b8c9d0e1f203",
"exam": {
"_id": "66f1a2b3c4d5e6f708192a3c",
"session": "66e0c1d2e3f4a5b6c7d8e9f0",
"category": "64b7e1f0a1b2c3d4e5f60711",
"language": "64a1f0b2c9d8e7f60011889a",
"grades": ["630e01826836e67ec53dc7a6", "630e01826836e67ec53dc7a7"],
"price": 25
},
"user": {
"_id": "66fa0b1c2d3e4f5a6b7c8d9e",
"mainId": "652f1c9b8e4b2a0012a3c4d5",
"firstName": "Ada",
"lastName": "Lovelace"
},
"payment": {
"_id": "6703d4e5f6a7b8c9d0e1f204",
"application": "6703d4e5f6a7b8c9d0e1f203",
"exam": "66f1a2b3c4d5e6f708192a3c",
"for": "66fa0b1c2d3e4f5a6b7c8d9e",
"amount": 25,
"status": "paid",
"createdAt": "2026-09-15T10:42:07.498Z",
"updatedAt": "2026-09-15T11:20:13.220Z"
},
"participated": false,
"partners": [],
"createdAt": "2026-09-15T10:42:07.512Z",
"updatedAt": "2026-09-16T08:05:51.004Z"
}
],
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}
Inside a resolved exam, the session, category and language are ids. To show their names, use the open exam list or the exam categories. A past exam no longer appears there, but its category still does.
Who appears in the lists
| List | Includes |
|---|---|
| All applications, and applications for an exam | Students of yours who have access to this organization and have signed in to it at least once. A student with neither never appears; the list isn't an error, it just leaves them out. |
| One student's applications | That student, if they are yours (404 not_found, Student not found! otherwise) and have signed in to this organization (409 conflict otherwise). |
All three lists include applications for closed and past exams. exam-applications/<examId> doesn't check that the exam exists: an unknown id returns an empty list, and it works for past sittings too. An <examId> or <studentId> that isn't 24 hexadecimal characters answers 400 bad_request.
Lists are paginated with page and limit (default 20, at most 100) and have no documented order. To take a complete snapshot, walk every page and remove duplicates by _id. See Pagination.
A single application
| Situation | Answer |
|---|---|
| Found and yours | 200, Application fetched successfully. |
| No application with this id | 404 not_found, Application not found! |
| It belongs to another account's student | 404 not_found, Not found! |
| The id isn't 24 hexadecimal characters | 400 bad_request |
The two 404s have different messages, but they mean the same thing to you: this application isn't available to your account. Branch on the status and error.code, never on the message.
The 409s at a glance
| Message starts with | Operation | What to do |
|---|---|---|
Student has never signed in to … | create, student list | Send a sign-in link, let the student follow it, then try again. |
Exam is not open for application | create, move | Choose another exam from the picker. |
Exam is not available for grade … | create, move | Choose an exam for the student's grade, or correct their grade if it's wrong. |
Exam is not available in this student’s country | create, move | Choose an exam offered in the student's country. |
Exam has no language set | create, move | Choose another exam. The organization hasn't set this one up for students. |
Student already has an application for … | create, move | Move the existing application instead of creating another. |
This exam has already been started | move | Nothing. A started exam stays where it is. |
An application for … cannot be moved to that category | move | Choose an exam in a category the old one accepts. |
Training has already started for this AI Challenge application | move | Nothing. This application stays where it is. |
Application has been paid for at … | move | Choose an exam at the same price. |
Application has been paid for and cannot be deleted | delete | Canceling a paid application is a refund. Arrange it outside the API. |
None of these changes on a retry. Fix the cause first. The conflict error page and Troubleshooting cover them too.