# Applications

> Enter a student for an exam, move them to another exam, withdraw them, and read their applications, with every rule and refusal in the order it's checked.

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](https://hub.main-team.org/api/guides/exams#the-per-student-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](https://hub.main-team.org/api/reference/create-application) | `POST /v1/<organizationId>/application` | `application/create` |
| [Move an application](https://hub.main-team.org/api/reference/move-application) | `PUT /v1/<organizationId>/application/<applicationId>` | `application/update` |
| [Delete an application](https://hub.main-team.org/api/reference/delete-application) | `DELETE /v1/<organizationId>/application/<applicationId>` | `application/delete` |
| [List applications](https://hub.main-team.org/api/reference/list-applications) | `GET /v1/<organizationId>/application` | `application/read` |
| [List applications for an exam](https://hub.main-team.org/api/reference/list-exam-applications) | `GET /v1/<organizationId>/application/exam-applications/<examId>` | `application/read` |
| [List a student's applications](https://hub.main-team.org/api/reference/list-student-applications) | `GET /v1/<organizationId>/application/student-applications/<studentId>` | `application/read` |
| [Get an application](https://hub.main-team.org/api/reference/get-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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/guides/sign-in-links) 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`).

```bash
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" }'
```

```json
{
  "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](https://hub.main-team.org/api/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](#move-an-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](#read-applications) to see where you stand. See also [Retries and idempotency](https://hub.main-team.org/api/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

```js
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
<?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](#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. |

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`).

```bash
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" }'
```

```json
{
  "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](#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](https://hub.main-team.org/api/tutorials/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>`](https://hub.main-team.org/api/reference/get-student).

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.

```bash
curl -s "https://api.main-team.org/v1/<organizationId>/application/student-applications/652f1c9b8e4b2a0012a3c4d5" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "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](https://hub.main-team.org/api/reference/list-exams) or the [exam categories](https://hub.main-team.org/api/reference/list-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](https://hub.main-team.org/api/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 `404`s 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](https://hub.main-team.org/api/guides/sign-in-links), 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`](https://hub.main-team.org/api/errors#conflict) error page and [Troubleshooting](https://hub.main-team.org/api/troubleshooting) cover them too.

## Related

- [Exams and the exam picker](https://hub.main-team.org/api/guides/exams)
- [Register a student and apply](https://hub.main-team.org/api/tutorials/register-and-apply)
- [Change an application](https://hub.main-team.org/api/tutorials/change-an-application)
- [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links)
- [Retries and idempotency](https://hub.main-team.org/api/retries-and-idempotency)
