Skip to content
API documentation
View as MarkdownOpen in Claude

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

OperationRequestPermission
Create an applicationPOST /v1/<organizationId>/applicationapplication/create
Move an applicationPUT /v1/<organizationId>/application/<applicationId>application/update
Delete an applicationDELETE /v1/<organizationId>/application/<applicationId>application/delete
List applicationsGET /v1/<organizationId>/applicationapplication/read
List applications for an examGET /v1/<organizationId>/application/exam-applications/<examId>application/read
List a student's applicationsGET /v1/<organizationId>/application/student-applications/<studentId>application/read
Get an applicationGET /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

RequirementIf it isn't met
The student is yours404 not_found, Student not found!
The student has signed in to this organization at least once, which creates the organization's copy of them409 conflict. Send them a sign-in link first.
The student has a grade400 bad_request
The exam is one the picker offers this student409 conflict, naming the rule that failed

Create an application

POST /v1/<organizationId>/application

FieldTypeRequiredRules
studentIdstringyesThe student's core id: the _id that registration returned. 24 hexadecimal characters.
examIdstringyesThe 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.

#CheckRefusal
1Token, organization, permission, rate limit and body, as on every route401, 404 (Organization not found!), 403, 429, 400 (for example examId must be a mongodb id)
2The student is yours404 not_found, Student not found!
3The organization holds a copy of the student409 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.
4The exam exists in this organization404 not_found, Exam not found!
5The exam is open409 conflict, Exam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to.
6The student has a grade400 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.
7The student's grade is one the exam accepts409 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.
8The exam is available in the student's country409 conflict, for example Exam is not available in this student’s country. It is offered in GERMANY, AUSTRIA.
9The exam has a language409 conflict, Exam has no language set, so it is not offered to students and cannot be applied to.
10The student already has this exact examnot a refusal: 200, Application already exists., with the existing application
11The student has no other exam in the same category on the same sitting409 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.
12All checks passed201, 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 pricePayment created
absent or 0amount: 0, status: "paid". A free exam needs no payment, so its record starts out settled.
a positive number, for example 25amount: 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 fieldMeaning
statuspending, paid or canceled
amountWhat the application costs. For a settled payment, what was actually paid.
exam, applicationWhat the payment is for.
forThe 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.

ActionEffect on the payment
CreateCreated as in What a new application contains.
Move, payment not settledRewritten to the new exam's price. status becomes paid if the new price is 0, and pending otherwise.
Move, payment settled, same priceOnly the exam it's for changes. The amount paid is left as it was.
Move, payment settled, different priceRefused with 409, because the API can't charge the difference or refund it.
Delete, not settledAllowed.
Delete, settledRefused 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

#CheckRefusal, and why
1Token, organization, permission, rate limit and bodyas on every route
2The application exists404 not_found, Application not found!. An <applicationId> that isn't 24 hexadecimal characters answers 400 bad_request.
3It belongs to one of your students404 not_found, Application not found!: the same answer as check 2, so a refusal never says whether the id exists
4The 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.
5The new exam exists404 not_found, Exam not found!
6The new exam is the one it already hasnot a refusal: 200, Application already uses that exam., with nothing changed. This makes a retried move safe.
7The picker offers the new exam to this studentthe same refusals as create checks 5 to 9: closed, no grade (400), grade, country, no language
8The old exam's category accepts the new category409 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).
9The student has no other exam in that category on that sitting409 conflict, the same message as create check 11
10The AI Challenge rule409 conflict, Training has already started for this AI Challenge application, so it can no longer be moved.
11A settled payment keeps its price409 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.
12All checks passed200, 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.

#CheckRefusal
1Token, organization, permission and rate limitas on every route
2The application exists404 not_found, Application not found!. An <applicationId> that isn't 24 hexadecimal characters answers 400 bad_request.
3It belongs to one of your students404 not_found, Application not found!: the same answer as check 2
4Its payment isn't settled409 conflict, Application has been paid for and cannot be deleted. Cancelling a paid application requires a refund, which this API does not perform.
5All checks passed200, 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:

OperationReturnsexamuserpayment
GET …/applicationEvery application of your students in this organizationdocumentstudent summarydocument
GET …/application/exam-applications/<examId>Your students' applications for one examidstudent summarydocument
GET …/application/student-applications/<studentId>One student's applicationsdocumentstudent summarydocument
GET …/application/<applicationId>One applicationdocumentstudent summarydocument

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

ListIncludes
All applications, and applications for an examStudents 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 applicationsThat 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

SituationAnswer
Found and yours200, Application fetched successfully.
No application with this id404 not_found, Application not found!
It belongs to another account's student404 not_found, Not found!
The id isn't 24 hexadecimal characters400 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 withOperationWhat to do
Student has never signed in to …create, student listSend a sign-in link, let the student follow it, then try again.
Exam is not open for applicationcreate, moveChoose another exam from the picker.
Exam is not available for grade …create, moveChoose an exam for the student's grade, or correct their grade if it's wrong.
Exam is not available in this student’s countrycreate, moveChoose an exam offered in the student's country.
Exam has no language setcreate, moveChoose another exam. The organization hasn't set this one up for students.
Student already has an application for …create, moveMove the existing application instead of creating another.
This exam has already been startedmoveNothing. A started exam stays where it is.
An application for … cannot be moved to that categorymoveChoose an exam in a category the old one accepts.
Training has already started for this AI Challenge applicationmoveNothing. This application stays where it is.
Application has been paid for at …moveChoose an exam at the same price.
Application has been paid for and cannot be deleteddeleteCanceling 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.

Search the API documentation

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