Skip to content
API documentation
View as MarkdownOpen in Claude

Tutorials

Change an application's language or sitting

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 idYesNo, the new one has a new id
The paymentStays attached. Repriced if unsettled, kept if settled and the price is the sameA paid application cannot be deleted at all
RequestsOneTwo, with a moment in between when the student holds nothing
Checks on the new examThe same as for a new applicationThe 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 beforeTarget's pricePayment after
pending or canceled, any amountAnyAmount set to the target's price; paid if that is 0, otherwise pending
paid, amount 0 (a free exam)0Unchanged: paid, amount 0
paid, amount 0 (a free exam)Above 0pending, for the target's price
paid, amount above 0The sameUnchanged; only the exam it pays for moves
paid, amount above 0DifferentThe move is refused with 409

Warning

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.
  • 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.

Step 1: load the application

curl -s https://api.main-team.org/v1/<organizationId>/application/66e6a4b1c1d2b30012a4fa07 \
  -H "Authorization: Bearer $TOKEN"
{
  "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:

FieldUse
exam.session, exam.category, exam.languageThe current sitting, category and language, as ids
user.mainIdThe student's core id, for the student and picker requests below
payment.status, payment.amountWhether the payment is settled: paid with an amount above 0
participated, examSubmittedIf 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:

curl -s "https://api.main-team.org/v1/<organizationId>/exam?page=1&limit=100" \
  -H "Authorization: Bearer $TOKEN"
{
  "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:

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.

Step 3: move it

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" }'
{
  "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:

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

StatusMessageWhat to do
404 not_foundApplication not found!The application does not exist on this organization, or is not one of your students'. The two answer alike
404 not_foundExam not found!The exam id is wrong, or belongs to another organization
409 conflictThis exam has already been started and can no longer be changed.Final. The entry stays as it is
409 conflictExam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to.Pick an exam from GET /exam
409 conflictExam 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 conflictExam is not available in this student’s country. It is offered in …Pick an exam offered in the student's country
409 conflictExam has no language set, so it is not offered to students and cannot be applied to.Pick another exam
409 conflictExam is not available to this student. Only exams returned by GET /:organizationId/exam/available/:studentId can be applied to.Pick another exam
409 conflictAn application for Mathematics cannot be moved to that category.This category may not be swapped for the target's. Stay in the category
409 conflictStudent 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 conflictTraining 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 conflictApplication 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_requestStudent has no grade set, and every exam is restricted to a set of grades.…Set the student's grade first, then move
400 bad_requestexamId must be a mongodb idThe body is malformed
400 bad_requestInvalid value for '_id': expected ObjectId.The application id in the path is not a 24-character hex id
403 forbiddenInsufficient role permissionsYour 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.

Removing an application instead

If the student wants out altogether, delete the application:

curl -s -X DELETE https://api.main-team.org/v1/<organizationId>/application/66e6a4b1c1d2b30012a4fa07 \
  -H "Authorization: Bearer $TOKEN"
{
  "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.

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

// 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: 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:

$ 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:

$ 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:

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

Search the API documentation

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