# Change an application's language or sitting

> Move an existing application to another exam, such as another language or date, and handle every refusal and what the move does to the payment.

A student you entered for Mathematics in English on the November sitting asks to write the paper in
German instead, or to move to the December sitting. You do not cancel and re-apply. You **move** the
existing application to the other exam with one `PUT`.

This tutorial shows how to find the right target exam, what the API checks and in which order, what
happens to the payment, and every refusal you can meet. It ends with a script in Node.js and PHP that
makes either change.

## Move, don't delete and apply again

| | Move (`PUT`) | Delete, then create |
|---|---|---|
| The application keeps its id | Yes | No, the new one has a new id |
| The payment | Stays attached. Repriced if unsettled, kept if settled and the price is the same | A paid application cannot be deleted at all |
| Requests | One | Two, with a moment in between when the student holds nothing |
| Checks on the new exam | The same as for a new application | The same |

A move is also what the student's own panel does when they change an entry, so both paths agree.

## What the API checks, in order

`PUT /v1/<organizationId>/application/<applicationId>` with `{ "examId": "…" }` runs these checks and
stops at the first that fails:

1. **The application exists and belongs to one of your students.** Otherwise `404 not_found`.
2. **The exam has not been started or handed in.** Otherwise `409`: answers are tied to the paper
   being sat, so a started exam stays where it is.
3. **The target exam exists on this organization.** Otherwise `404`, `Exam not found!`.
4. **The target is different.** If it is the exam the application already has, the answer is `200`
   with `Application already uses that exam.` and nothing changes, so a retried move is harmless.
5. **The student could be offered the target.** The same rules as a new application: open for
   applications, the student's grade and country, a language set. The application being moved does
   not count against itself, so swapping languages on the same sitting is allowed. Otherwise `409`,
   naming the rule.
6. **The current exam's category accepts the target's category as a replacement.** Some categories
   may not be swapped for others. Otherwise `409`.
7. **No clash.** The student holds no other application in the target's category on the target's
   sitting. Otherwise `409`.
8. **Not a started AI Challenge.** An AI Challenge application whose image quota has already been used
   cannot be moved. Otherwise `409`.
9. **The price still fits a settled payment.** If the application has been paid for (status `paid`
   with an amount above 0), the target must cost exactly the same. Otherwise `409`: this API neither
   charges a difference nor refunds one.

## What happens to the payment

| Payment before | Target's price | Payment after |
|---|---|---|
| `pending` or `canceled`, any amount | Any | Amount set to the target's price; `paid` if that is 0, otherwise `pending` |
| `paid`, amount 0 (a free exam) | 0 | Unchanged: `paid`, amount 0 |
| `paid`, amount 0 (a free exam) | Above 0 | `pending`, for the target's price |
| `paid`, amount above 0 | The same | Unchanged; only the exam it pays for moves |
| `paid`, amount above 0 | Different | The move is refused with `409` |

**Moving a free entry onto a priced exam leaves the student owing the new price.** Read the target's
`price` before you move. An exam with no `price` field is free.

One more rule: an application on a sitting that is linked to another sitting, such as a make-up
sitting, is temporary. It is set to expire 6 hours after you create it there or move it there, the same
as when a student makes that entry in their own panel. Moving an application off such a sitting
removes the expiry.

## Before you start

- **Permissions** on the organization: `application/read` and `application/update`, plus `exam/read`
  to find the target. To read the student's grade you need `student/read` on `mto`. Deleting (at the
  end) needs `application/delete`. See [Permissions](https://hub.main-team.org/api/permissions).
- **The application's id.** You got it when you created the application. Otherwise list the student's
  applications with `GET /v1/<organizationId>/application/student-applications/<studentId>`.
- **The helper file** `mainteam.mjs` or `mainteam.php` from
  [Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply#the-helper-file).

## Step 1: load the application

```bash [curl]
curl -s https://api.main-team.org/v1/<organizationId>/application/66e6a4b1c1d2b30012a4fa07 \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200 (abridged)]
{
  "success": true,
  "message": "Application fetched successfully.",
  "data": {
    "_id": "66e6a4b1c1d2b30012a4fa07",
    "exam": {
      "_id": "64c0a1b2c3d4e5f601234801",
      "session": "64c0a1b2c3d4e5f601234601",
      "category": "64c0a1b2c3d4e5f601234501",
      "language": "64c0a1b2c3d4e5f601234701",
      "grades": ["630e01826836e67ec53dc7a6"],
      "price": 25
    },
    "user": {
      "_id": "66e6a41fc1d2b30012a4f9f3",
      "mainId": "66e6a3f5c1d2b30012a4f9c1",
      "firstName": "Jane",
      "lastName": "Doe"
    },
    "payment": {
      "_id": "66e6a4b1c1d2b30012a4fa09",
      "amount": 25,
      "status": "paid"
    },
    "participated": false
  }
}
```

What to take from it:

| Field | Use |
|---|---|
| `exam.session`, `exam.category`, `exam.language` | The current sitting, category and language, as ids |
| `user.mainId` | The student's core id, for the student and picker requests below |
| `payment.status`, `payment.amount` | Whether the payment is settled: `paid` with an amount above 0 |
| `participated`, `examSubmitted` | If either is `true`, the exam has started and the move will be refused. Either may be absent, which means not started |

## Step 2: find the target exam

### Another language on the same sitting

The student's picker (`GET /v1/<organizationId>/exam/available/<studentId>`) leaves out any category
and sitting the student already holds, **in every language**. It cannot show you this move. Use the
organization's list of open exams instead. It is paginated, soonest sitting first, and each exam
carries its `session`, `category`, `language`, `grades` and `countries` as documents:

```bash [curl]
curl -s "https://api.main-team.org/v1/<organizationId>/exam?page=1&limit=100" \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200 (abridged)]
{
  "success": true,
  "message": "Exams fetched successfully.",
  "data": [
    {
      "_id": "64c0a1b2c3d4e5f601234802",
      "session": { "_id": "64c0a1b2c3d4e5f601234601", "date": "2026-11-14T09:00:00.000Z" },
      "category": { "_id": "64c0a1b2c3d4e5f601234501", "name": "Mathematics" },
      "language": { "_id": "64c0a1b2c3d4e5f601234702", "name": "German", "code": "de" },
      "grades": [{ "_id": "630e01826836e67ec53dc7a6", "name": "10" }],
      "countries": [],
      "price": 25
    }
  ],
  "pagination": { "page": 1, "limit": 100, "total": 38, "totalPages": 1 }
}
```

Pick the exam whose `session._id` and `category._id` match the current ones, whose `language.code` is
the one the student wants, and whose `grades` include the student's grade. The grade is on
`GET /v1/student/<studentId>`, and grade ids are the same on every organization. If the payment is
settled, also check that `price` equals the amount paid. The API makes the final decision, country
rules included, and names the reason if it refuses.

`GET /exam` lists open exams only. An exam missing from it cannot be moved to.

### Another sitting

For a different date the student's picker is the right tool. It lists the other sittings of each
category, and leaves out only the sitting the student already holds:

```bash [curl]
curl -s https://api.main-team.org/v1/<organizationId>/exam/available/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN"
```

Find the category whose `_id` matches the current category, choose a sitting under it, and take
`matchedExam._id` from the language you want. The tree is described step by step in
[Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply#step-6-fetch-the-students-exam-picker).

## Step 3: move it

```bash [curl]
curl -s -X PUT https://api.main-team.org/v1/<organizationId>/application/66e6a4b1c1d2b30012a4fa07 \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "examId": "64c0a1b2c3d4e5f601234802" }'
```

```json [Response 200 (abridged)]
{
  "success": true,
  "message": "Application updated successfully.",
  "data": {
    "_id": "66e6a4b1c1d2b30012a4fa07",
    "exam": "64c0a1b2c3d4e5f601234802",
    "user": "66e6a41fc1d2b30012a4f9f3",
    "payment": "66e6a4b1c1d2b30012a4fa09"
  }
}
```

The body takes `examId` and nothing else. The student is not in the body, because the application
already names them. Any other field is refused with `400`.

Sending the exam the application already has answers `200` with `Application already uses that exam.`
and changes nothing.

## Step 4: check the payment

Read the application again to see where the move left the payment:

```bash [curl]
curl -s https://api.main-team.org/v1/<organizationId>/application/66e6a4b1c1d2b30012a4fa07 \
  -H "Authorization: Bearer $TOKEN"
```

For the move above, from a settled payment of 25 to another exam at 25, the payment is unchanged:
`"status": "paid"` and `"amount": 25`, and it now pays for the German exam. Had the payment been
`pending`, it would now show the new exam's price.

## Refusals

| Status | Message | What to do |
|---|---|---|
| `404 not_found` | `Application not found!` | The application does not exist on this organization, or is not one of your students'. The two answer alike |
| `404 not_found` | `Exam not found!` | The exam id is wrong, or belongs to another organization |
| `409 conflict` | `This exam has already been started and can no longer be changed.` | Final. The entry stays as it is |
| `409 conflict` | `Exam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to.` | Pick an exam from `GET /exam` |
| `409 conflict` | `Exam is not available for grade 10. It accepts grade 11, 12.` | Pick an exam for the student's grade, or correct the grade first |
| `409 conflict` | `Exam is not available in this student’s country. It is offered in …` | Pick an exam offered in the student's country |
| `409 conflict` | `Exam has no language set, so it is not offered to students and cannot be applied to.` | Pick another exam |
| `409 conflict` | `Exam is not available to this student. Only exams returned by GET /:organizationId/exam/available/:studentId can be applied to.` | Pick another exam |
| `409 conflict` | `An application for Mathematics cannot be moved to that category.` | This category may not be swapped for the target's. Stay in the category |
| `409 conflict` | `Student already has an application for Mathematics on this sitting. Two exams in one category on one date cannot both be sat.` | The student holds that sitting through another application. Move or delete that one first |
| `409 conflict` | `Training has already started for this AI Challenge application, so it can no longer be moved.` | Final. The AI Challenge image quota has been used |
| `409 conflict` | `Application has been paid for at 25, and that exam costs 30. Moving it would change what the payment bought, and this API neither charges a difference nor refunds.` | Pick an exam at the same price |
| `400 bad_request` | `Student has no grade set, and every exam is restricted to a set of grades.…` | Set the student's grade first, then move |
| `400 bad_request` | `examId must be a mongodb id` | The body is malformed |
| `400 bad_request` | `Invalid value for '_id': expected ObjectId.` | The application id in the path is not a 24-character hex id |
| `403 forbidden` | `Insufficient role permissions` | Your account lacks `application/update` on this organization |

A `409` never changes anything, so you can show its message to your staff and let them choose another
exam. Do not retry a `409` unchanged; it gives the same answer. See [Errors](https://hub.main-team.org/api/errors#conflict).

## Removing an application instead

If the student wants out altogether, delete the application:

```bash [curl]
curl -s -X DELETE https://api.main-team.org/v1/<organizationId>/application/66e6a4b1c1d2b30012a4fa07 \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200 (abridged)]
{
  "success": true,
  "message": "Application deleted successfully.",
  "data": { "_id": "66e6a4b1c1d2b30012a4fa07", "exam": "64c0a1b2c3d4e5f601234802" }
}
```

- **A paid application cannot be deleted.** If the payment is `paid` with an amount above 0, the answer
  is `409` with `Application has been paid for and cannot be deleted. Cancelling a paid application
  requires a refund, which this API does not perform.` A free application (`paid`, amount 0) and an
  unpaid one can be deleted.
- **A second delete answers `404`**, `Application not found!`. If a delete timed out, a `404` on the
  retry means the first one worked.
- Deleting needs `application/delete`. If your integration should never delete, ask your operator for
  a `disallow` role on `application/delete`. It wins over any allow. See [Permissions](https://hub.main-team.org/api/permissions).

## The complete script

The script takes the organization slug, the application id and the kind of change. For `language` it
searches the open exams for the same category and sitting in the language you name. For `sitting` it
takes the soonest other sitting of the same category from the student's picker, keeping the language
when it can. Either way it respects a settled payment's price, moves the application, and prints the
payment before and after.

### Node.js

```js [change-application.mjs]
// change-application.mjs: move an application to another language or sitting.
// Usage:
//   node change-application.mjs stem <applicationId> language de
//   node change-application.mjs stem <applicationId> sitting
import { api, ApiError } from './mainteam.mjs';

const [slug, applicationId, mode, languageCode] = process.argv.slice(2);
if (!slug || !applicationId || !['language', 'sitting'].includes(mode) || (mode === 'language' && !languageCode)) {
  console.error('Usage: node change-application.mjs <slug> <applicationId> language <code> | sitting');
  process.exit(2);
}

// A reference may arrive as a document or as a bare id.
const idOf = (value) => (value && typeof value === 'object' ? value._id : value);

async function organizationId(wanted) {
  const res = await api('GET', '/organization?limit=100');
  const org = res.data.find((candidate) => candidate.slug === wanted);
  if (!org) throw new Error(`No organization with slug ${wanted}.`);
  return org._id;
}

async function openExams(orgId) {
  const exams = [];
  for (let page = 1; ; page++) {
    const res = await api('GET', `/${orgId}/exam?page=${page}&limit=100`);
    exams.push(...res.data);
    if (page >= res.pagination.totalPages) return exams;
  }
}

async function main() {
  const orgId = await organizationId(slug);
  const { data: application } = await api('GET', `/${orgId}/application/${applicationId}`);
  const current = application.exam; // a document; its session, category and language are ids
  const payment = application.payment;
  const settled = payment?.status === 'paid' && payment.amount > 0;
  const studentId = application.user.mainId;
  const fits = (exam) => !settled || (exam.price ?? 0) === payment.amount;

  console.log(`Application ${application._id} for ${application.user.firstName} ${application.user.lastName}`);
  console.log(`  now: exam ${current._id}, payment ${payment?.status ?? 'none'} ${payment?.amount ?? ''}`);

  if (application.participated || application.examSubmitted) {
    console.log('  The exam has been started, so it can no longer be moved.');
    return;
  }

  let target;
  if (mode === 'language') {
    const { data: student } = await api('GET', `/student/${studentId}`);
    const gradeId = idOf(student?.grade);
    target = (await openExams(orgId)).find(
      (exam) =>
        idOf(exam.session) === idOf(current.session) &&
        idOf(exam.category) === idOf(current.category) &&
        exam.language?.code === languageCode &&
        exam.grades.some((grade) => idOf(grade) === gradeId) &&
        fits(exam),
    );
  } else {
    const { data: tree } = await api('GET', `/${orgId}/exam/available/${studentId}`);
    const category = tree.find((node) => node._id === idOf(current.category));
    const candidates = (category?.sessions ?? [])
      .flatMap((session) => session.languages.map((language) => language.matchedExam))
      .filter(fits);
    target = candidates.find((exam) => exam.language === idOf(current.language)) ?? candidates[0];
  }

  if (!target) {
    console.log('  No suitable exam was found. Nothing was changed.');
    return;
  }
  console.log(`  target: exam ${target._id} (price ${target.price ?? 0})`);

  const moved = await api('PUT', `/${orgId}/application/${applicationId}`, { examId: target._id });
  console.log(`  ${moved.message}`);

  const { data: after } = await api('GET', `/${orgId}/application/${applicationId}`);
  console.log(`  now: exam ${after.exam._id}, payment ${after.payment?.status} ${after.payment?.amount}`);
}

main().catch((error) => {
  if (error instanceof ApiError) {
    console.error(`${error.status} ${error.code}: ${error.message} (request ${error.requestId})`);
  } else {
    console.error(error);
  }
  process.exitCode = 1;
});
```

### PHP

```php [change-application.php]
<?php
// change-application.php: move an application to another language or sitting.
// Usage:
//   php change-application.php stem <applicationId> language de
//   php change-application.php stem <applicationId> sitting
declare(strict_types=1);
require __DIR__ . '/mainteam.php';

[$slug, $applicationId, $mode, $languageCode] = array_pad(array_slice($argv, 1), 4, null);
if ($slug === null || $applicationId === null || !in_array($mode, ['language', 'sitting'], true)
    || ($mode === 'language' && $languageCode === null)) {
    fwrite(STDERR, "Usage: php change-application.php <slug> <applicationId> language <code> | sitting\n");
    exit(2);
}

// A reference may arrive as a document or as a bare id.
function idOf(mixed $value): ?string
{
    return is_array($value) ? ($value['_id'] ?? null) : $value;
}

function organizationId(MainTeam $api, string $wanted): string
{
    foreach ($api->call('GET', '/organization?limit=100')['data'] as $org) {
        if ($org['slug'] === $wanted) {
            return $org['_id'];
        }
    }
    throw new RuntimeException("No organization with slug $wanted.");
}

function openExams(MainTeam $api, string $orgId): array
{
    $exams = [];
    for ($page = 1; ; $page++) {
        $res = $api->call('GET', "/$orgId/exam?page=$page&limit=100");
        array_push($exams, ...$res['data']);
        if ($page >= $res['pagination']['totalPages']) {
            return $exams;
        }
    }
}

$api = MainTeam::fromEnv();

try {
    $orgId = organizationId($api, $slug);
    $application = $api->call('GET', "/$orgId/application/$applicationId")['data'];
    $current = $application['exam']; // a document; its session, category and language are ids
    $payment = $application['payment'] ?? null;
    $settled = ($payment['status'] ?? null) === 'paid' && ($payment['amount'] ?? 0) > 0;
    $studentId = $application['user']['mainId'];
    $fits = fn (array $exam): bool => !$settled || ($exam['price'] ?? 0) == $payment['amount'];

    echo "Application {$application['_id']} for {$application['user']['firstName']} {$application['user']['lastName']}\n";
    printf("  now: exam %s, payment %s %s\n", $current['_id'], $payment['status'] ?? 'none', $payment['amount'] ?? '');

    if (!empty($application['participated']) || !empty($application['examSubmitted'])) {
        echo "  The exam has been started, so it can no longer be moved.\n";
        exit(0);
    }

    $target = null;
    if ($mode === 'language') {
        $student = $api->call('GET', "/student/$studentId")['data'] ?? null;
        $gradeId = idOf($student['grade'] ?? null);
        foreach (openExams($api, $orgId) as $exam) {
            $gradeIds = array_map('idOf', $exam['grades'] ?? []);
            if (idOf($exam['session']) === idOf($current['session'])
                && idOf($exam['category']) === idOf($current['category'])
                && ($exam['language']['code'] ?? null) === $languageCode
                && in_array($gradeId, $gradeIds, true)
                && $fits($exam)) {
                $target = $exam;
                break;
            }
        }
    } else {
        $tree = $api->call('GET', "/$orgId/exam/available/$studentId")['data'];
        $candidates = [];
        foreach ($tree as $category) {
            if ($category['_id'] !== idOf($current['category'])) {
                continue;
            }
            foreach ($category['sessions'] as $session) {
                foreach ($session['languages'] as $language) {
                    if ($fits($language['matchedExam'])) {
                        $candidates[] = $language['matchedExam'];
                    }
                }
            }
        }
        foreach ($candidates as $exam) {
            if ($exam['language'] === idOf($current['language'])) {
                $target = $exam;
                break;
            }
        }
        $target ??= $candidates[0] ?? null;
    }

    if ($target === null) {
        echo "  No suitable exam was found. Nothing was changed.\n";
        exit(0);
    }
    printf("  target: exam %s (price %s)\n", $target['_id'], $target['price'] ?? 0);

    $moved = $api->call('PUT', "/$orgId/application/$applicationId", ['examId' => $target['_id']]);
    echo "  {$moved['message']}\n";

    $after = $api->call('GET', "/$orgId/application/$applicationId")['data'];
    printf("  now: exam %s, payment %s %s\n", $after['exam']['_id'], $after['payment']['status'] ?? '-', $after['payment']['amount'] ?? '-');
} catch (ApiError $e) {
    fwrite(STDERR, "{$e->status} {$e->errorCode}: {$e->getMessage()} (request {$e->requestId})\n");
    exit(1);
}
```

### Expected output

A language swap on a settled payment, at the same price:

```text
$ node change-application.mjs stem 66e6a4b1c1d2b30012a4fa07 language de
Application 66e6a4b1c1d2b30012a4fa07 for Jane Doe
  now: exam 64c0a1b2c3d4e5f601234801, payment paid 25
  target: exam 64c0a1b2c3d4e5f601234802 (price 25)
  Application updated successfully.
  now: exam 64c0a1b2c3d4e5f601234802, payment paid 25
```

A move to the next sitting from a free entry, where the next sitting has a price. The payment becomes
`pending`:

```text
$ node change-application.mjs stem 66e6a4b1c1d2b30012a4fa07 sitting
Application 66e6a4b1c1d2b30012a4fa07 for Jane Doe
  now: exam 64c0a1b2c3d4e5f601234801, payment paid 0
  target: exam 64c0a1b2c3d4e5f601234901 (price 30)
  Application updated successfully.
  now: exam 64c0a1b2c3d4e5f601234901, payment pending 30
```

A refused move prints the reason and changes nothing:

```text
409 conflict: Student already has an application for Mathematics on this sitting. Two exams in one category on one date cannot both be sat. (request 5b2d8c1e-0f3a-4e7b-9c61-2a4d7e8f9b10)
```

## Next steps

- [Applications](https://hub.main-team.org/api/guides/applications): every application rule, including the three list variants.
- [Exams](https://hub.main-team.org/api/guides/exams): what "open" means and how the picker is built.
- [Retries and idempotency](https://hub.main-team.org/api/retries-and-idempotency): which of these requests are safe to repeat.
- Reference: [getApplication](https://hub.main-team.org/api/reference/get-application),
  [moveApplication](https://hub.main-team.org/api/reference/move-application),
  [deleteApplication](https://hub.main-team.org/api/reference/delete-application), [listExams](https://hub.main-team.org/api/reference/list-exams).
