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 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:
- The application exists and belongs to one of your students. Otherwise
404 not_found. - 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. - The target exam exists on this organization. Otherwise
404,Exam not found!. - The target is different. If it is the exam the application already has, the answer is
200withApplication already uses that exam.and nothing changes, so a retried move is harmless. - 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. - The current exam's category accepts the target's category as a replacement. Some categories
may not be swapped for others. Otherwise
409. - No clash. The student holds no other application in the target's category on the target's
sitting. Otherwise
409. - Not a started AI Challenge. An AI Challenge application whose image quota has already been used
cannot be moved. Otherwise
409. - The price still fits a settled payment. If the application has been paid for (status
paidwith an amount above 0), the target must cost exactly the same. Otherwise409: 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 |
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/readandapplication/update, plusexam/readto find the target. To read the student's grade you needstudent/readonmto. Deleting (at the end) needsapplication/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.mjsormainteam.phpfrom 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:
| 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:
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
| 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.
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
paidwith an amount above 0, the answer is409withApplication 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, a404on the retry means the first one worked. - Deleting needs
application/delete. If your integration should never delete, ask your operator for adisallowrole onapplication/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
- Applications: every application rule, including the three list variants.
- Exams: what "open" means and how the picker is built.
- Retries and idempotency: which of these requests are safe to repeat.
- Reference: getApplication, moveApplication, deleteApplication, listExams.