Help
Troubleshooting
Start with the status code, then find the error code and message below. Each section lists the likely causes in order, most common first, with the fix for each. If you are stuck at the end, the last section says what to send support.
Read the error first
Every error has the same shape. Here is a real one, from a request that tried to set a password:
{
"error": {
"code": "conflict",
"message": "This student has confirmed their email address, so the password is theirs to change. Send them a sign-in link with POST /v1/:organizationId/auth/signin.",
"documentation_url": "https://hub.main-team.org/api/errors#conflict",
"request_id": "7c1e9a52-3f0b-4d8e-9a61-2b5d0c4e8f13"
}
}
- The HTTP status and
codesay what kind of failure this is. Branch your code on these two, never on the message text, because messages can be reworded. messageis written for a person. It often names the rule and the fix. The tables below quote messages exactly, so you can search this page for yours.request_ididentifies this one request. It also comes back in theX-Request-Idheader of every response, successful or not. Log it with the error. Support needs it to find the request.
The two file downloads are the only operations that do not answer JSON when they succeed. When they fail, they answer with this same JSON error.
The order the API checks a request
The API checks a request in a fixed order and answers with the first problem it finds. This explains why fixing one error can reveal another.
| Step | What is checked | Refusal |
|---|---|---|
| 1 | The request body can be read | 413 payload_too_large, 415 unsupported_media_type, or 400 bad_request for malformed JSON |
| 2 | The path exists | 404 not_found |
| 3 | The token | 401 unauthorized |
| 4 | The {organizationId} in the path, if there is one | 404 not_found, Organization not found! |
| 5 | Your roles grant the operation | 403 forbidden, Insufficient role permissions |
| 6 | The rate limit | 429 too_many_requests |
| 7 | The body's fields | 400 bad_request |
| 8 | The operation's own rules: the ids in the path, ownership, open exams, payments | 400, 403, 404 or 409, with a specific message |
Consequences:
- A request without a valid token gets
401whatever organization id it names, so the API never reveals which organizations exist to someone without a token. - A request refused at steps 1 to 5 does not count toward your rate limit. A request refused at step 7 or 8 does.
Status 401: unauthorized
The message is always Authentication is required or the provided credentials are invalid., whatever the reason. Work through these causes in order.
- The header. It must be exactly
Authorization: Bearer <token>: a capitalB, one space, then the token, with no quotes and no line break.bearer,Tokenand a doubled space are all refused. kidis missing or wrong. The token header must carrykidwith yourapiKeyexactly as issued:key_followed by 24 characters. Many libraries addkidonly when asked. Injsonwebtoken, use thekeyidoption.subdoes not equalkid. The payload'ssubmust be yourapiKeytoo.iatorexpis missing. Both are required, as whole seconds since the Unix epoch, not milliseconds. Some libraries setiatonly when asked. Injose, call.setIssuedAt().- The lifetime is too long.
exp - iatmay be at most 3600.expiresIn: '2h'or'1d'produces a token that is refused, even though it has not expired. - Your clock is wrong. An
iatmore than 30 seconds in the future is refused. So is a token more than 30 seconds past itsexp. Check the server withdate -uand keep it synchronized with NTP. - The signature. The token must be signed with HS256, using the whole
apiSecretstring, including itssecret_prefix, as the key. Look for secrets read from a file or environment variable with a trailing newline or surrounding quotes, and for tokens signed with theapiKeyby mistake. - The token was revoked, or it simply expired while a long job was running. Sign a new one.
- The account is inactive, or was just created or changed. A deactivated account's tokens stop working within a minute. A brand-new or reactivated account can take up to a minute to start working. If you get
401right after the operator says it is ready, wait a minute and try again.
This Node.js script checks a token against rules 2 to 6 without calling the API:
// check-token.js — usage: node check-token.js "$TOKEN"
const token = process.argv[2];
const [header, payload] = token
.split('.')
.slice(0, 2)
.map((part) => JSON.parse(Buffer.from(part, 'base64url').toString('utf8')));
const now = Math.floor(Date.now() / 1000);
const checks = {
'alg is HS256': header.alg === 'HS256',
'kid looks like an apiKey': /^key_[A-Za-z0-9_-]{24}$/.test(header.kid ?? ''),
'sub equals kid': payload.sub === header.kid,
'iat and exp are whole seconds': Number.isInteger(payload.iat) && Number.isInteger(payload.exp),
'exp is after iat': payload.exp > payload.iat,
'lifetime is at most 3600 s': payload.exp - payload.iat <= 3600,
'iat is not more than 30 s ahead': payload.iat - now <= 30,
'not expired (30 s tolerance)': payload.exp + 30 > now,
};
for (const [rule, ok] of Object.entries(checks)) console.log(ok ? 'ok ' : 'FAIL', rule);
The same checks in PHP:
<?php
// check-token.php — usage: php check-token.php "$TOKEN"
[$h, $p] = array_map(
fn (string $part) => json_decode(base64_decode(strtr($part, '-_', '+/')), true),
array_slice(explode('.', $argv[1]), 0, 2)
);
$now = time();
$checks = [
'alg is HS256' => ($h['alg'] ?? null) === 'HS256',
'kid looks like an apiKey' => (bool) preg_match('/^key_[A-Za-z0-9_-]{24}$/', $h['kid'] ?? ''),
'sub equals kid' => ($p['sub'] ?? null) === ($h['kid'] ?? null),
'iat and exp are whole seconds' => is_int($p['iat'] ?? null) && is_int($p['exp'] ?? null),
'exp is after iat' => ($p['exp'] ?? 0) > ($p['iat'] ?? 0),
'lifetime is at most 3600 s' => ($p['exp'] ?? 0) - ($p['iat'] ?? 0) <= 3600,
'iat is not more than 30 s ahead' => ($p['iat'] ?? 0) - $now <= 30,
'not expired (30 s tolerance)' => ($p['exp'] ?? 0) + 30 > $now,
];
foreach ($checks as $rule => $ok) echo ($ok ? 'ok ' : 'FAIL ') . $rule . PHP_EOL;
If every check passes and you still get 401, the likely cause is the signature (rule 7) or the account (rules 8 and 9). Send the request_id to support. We can see which check failed. Never send the token itself.
Security
Never paste a token or your apiSecret into an online JWT debugger. A token is a working credential until it expires, and the secret signs new ones.
Status 403: forbidden
"Insufficient role permissions"
Your token is valid, but none of your roles grants this operation on this organization. Retrying or signing a new token will not help.
- Find the permission. Each operation's reference page names the permission it needs, for example
application/create. Compare it with your roles.GET /v1/api-account/validate-mereturns them, provided you holdapi/*. - Check the target. An operation with
{organizationId}in its path needs a role whose target is that organization's slug, or*. An operation without one (/v1/student,/v1/country,/v1/grade,/v1/organization,/v1/api-account) needsmtoor*. api/*is its own action. The two/v1/api-accountoperations needapi/*,*/*or*. A role for*/readdoes not grant them.auth/signinis its own action. Sign-in links and passwords need it, and nostudent/*orapi/*role implies it.- A
disallowrole wins. If any matching role saysdisallow, the operation is refused, however many roles allow it. authorizedmust be empty or your own account id. A role whoseauthorizednames a different id does nothing for your account.- The change is recent. Role changes take up to a minute to reach every request.
Only an operator can change roles. Tell support which operations you need, and see Permissions for ready-made role sets.
Other 403 messages
| Message | Operation | Cause and fix |
|---|---|---|
Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs. | registerStudent | You sent password without holding auth/signin on mto. Nothing was created. Leave password out, or ask for the permission. |
Student is not activated for organization <slug>. | createSigninLink | The student has no access to this organization. Grant it with updateOrgStudent, then create the link again. |
Status 404: not_found
A 404 can come from the path, from the organization, or from the operation itself. The message tells you which.
| Message | What it means | What to check |
|---|---|---|
Organization not found! | The {organizationId} is not one of the organizations GET /v1/organization lists. | Use the _id from GET /v1/organization, never the slug (stem) or the name. It must be exactly 24 hexadecimal characters. |
Student not found! | No student with this id belongs to your account, or, on getOrgStudent, the student has no access to that organization. | Use the id that registration returned, the core-record _id. On organization data, such as the user of an application, that id is in mainId. user._id there is the organization's own id and does not work on student operations. |
Application not found! | No application of your students has this id on this organization. It does not exist there, or it belongs to another account's student. | Application ids belong to one organization. Check that the {organizationId} is the one you created the application on. |
Not found! | On a download or a supervisor link: one of several checks failed, and the answer never says which. | See Certificate or report download and the supervisor link. |
Exam not found! | No exam with this id exists on this organization. | Exam ids belong to one organization. Take them from that organization's lists. |
Exam is not open for application, so it is not available through this API. | The exam exists but is closed. | Only exams in listExams can be read or applied for. |
| A message naming the method and path | The path does not exist. | Check the spelling, the /v1 prefix and the HTTP method against the reference. |
An application held by another account's student answers 404 Application not found! on every operation, exactly like an id that does not exist.
The supervisor link always answers 404
linkStudentSupervisor answers the same 404 Not found! to every refusal, so the message never says which check failed. Check each of these:
- The student is yours. Use the student's core-record id.
- The student has signed in to this organization at least once, which creates the organization's copy of them.
- The username is spelled correctly. Leading and trailing spaces are removed and case does not matter. A username made only of spaces is a
404too. - The account behind the username is a supervisor on this organization. A supervisor on one organization is not automatically a supervisor on another.
A body with no supervisorUsername, or an empty one, is 400, not 404.
Status 400: bad_request
"property … should not exist"
The body carries a field the operation does not accept. The API refuses unknown fields rather than ignoring them, so you never receive a success for a request that did less than you asked.
- Sending a record back. Fields such as
_id,mainId,username,fullName,emailConfirmed,supervisor,createdAtandupdatedAtare read-only. Send only the fields you are changing. passwordon an update. Passwords are set only at registration or withsetStudentPassword.- Anything on an application besides
examId(to move one) or besidesstudentIdandexamId(to create one).
Unknown query parameters are ignored. Of the ones an operation reads, page and limit never cause an error, while the student list filters, email and signedIn, answer 400 when they cannot be read (see Students).
Only one problem is reported at a time
A 400 names the first problem found. After you fix it, the next request may report another one. Validate on your side, against the field rules in the reference, to catch them all at once.
A field you sent is reported as missing or invalid
If the message complains about a field you did send, for example examId must be a mongodb id, the API did not read your body as JSON. Send Content-Type: application/json and a JSON body.
"Invalid value for '_id': expected ObjectId."
An id in the path is not 24 hexadecimal characters. The quoted name is the field the id was looked up in: usually _id, and exam on the list of one exam's applications. Common causes are a slug or username where an id belongs, a truncated id, or trailing whitespace.
Two ids behave differently. A malformed {organizationId} answers 404 Organization not found!. The certificate and report downloads also accept a shortId, so a malformed id there answers 404 Not found!.
Field messages
| Message (examples) | Fix |
|---|---|
firstName should not be empty, email must be an email | Send the required field with a valid value. |
birth must be a real date in DD/MM/YYYY format | Send 14/05/2011, not 2011-05-14. |
sex must be one of the following values: m, f, n | Use one of the three codes. |
country must be a mongodb id | country takes only an _id from listCountries. |
email2 must be empty | Leave email2 out. |
redirect must be a site-relative path starting with "/" (e.g. "/dashboard") | Send a path such as /dashboard, not a full URL. |
supervisorUsername should not be empty | Send the supervisor's username. |
password must … | The message names the rule that failed: at least 5 characters, at most 72 bytes, and no part of the student's name, username or email address. See Passwords. |
email must be a valid email address | On an update, email was null. Leave it out to keep the current address. An empty string is refused as email should not be empty. |
Reference data messages
The API never creates a country, grade, city or school. It looks up what you send, and refuses what it cannot find.
| Message | Fix |
|---|---|
country is not a known country. | The id does not exist, or the country cannot be selected. Use an id from listCountries. |
grade is not a known grade. This API does not create reference data. | Use a grade _id from listGrades, or a name from "1" to "12". |
city is not a known city. This API does not create reference data. | A city name is looked up within the student's country. Check the spelling and the country, or send the city's _id. |
school is not a known school. This API does not create reference data. | A school name is looked up within the student's country and city. Check all three. |
city was given as a name, which can only be resolved together with country. | The student has no country to look the name up in. Send country in the same request, or the city's _id. |
school was given as a name, which can only be resolved together with country. | A school name needs both a country and a city, and the student lacks one of them. Send both in the same request, or the school's _id. |
When you send an _id that matches nothing, the message is the short form, for example grade is not a known grade.
Grade messages on exams and applications
Student has no grade set, and every exam is restricted to a set of grades. Set one with PUT /:organizationId/student/:studentId before … means the student has no grade. Set one on either student update operation, then try again.
Status 409: conflict
A 409 means the request is well-formed, but the current state of a record blocks it. The message names the rule. None of these clears if you simply retry. Change the input, or do the step the message names first.
Students and passwords
| Message | Cause and fix |
|---|---|
A student with that email is already registered to this account (<studentId>). | You registered this address before. Fetch or update the student with the id in the message. |
That email address is already registered. | Someone else on the platform uses this address. On an update, it can also be another of your own students. Use a different address. |
This student has confirmed their email address, so the password is theirs to change. … | You can no longer set this student's password. Send them a sign-in link instead. |
"Student has never signed in to …"
Student has never signed in to <slug>, so <slug> holds no record for them. Generate a sign-in link first with POST /:organizationId/auth/signin.
This appears on applications, on a student's application list, and on certificate and report lists. The organization creates its copy of a student the first time the student opens a sign-in link for it. Create a link, have the student open it in a browser, then try again. Creating the link without opening it is not enough.
Creating or moving an application
The API checks these in order and reports the first that applies.
| Message | Cause and fix |
|---|---|
Exam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to. | The session has passed, the category is inactive, or applications are switched off. Pick an exam from the student's available exams. |
Exam is not available for grade <grade>. It accepts grade <grades>. | The exam does not take the student's grade. Pick another exam, or correct the student's grade if it is wrong. |
Exam is not available in this student’s country. It is offered in <countries>. | The exam is restricted to other countries. |
Exam has no language set, so it is not offered to students and cannot be applied to. | This exam cannot be taken by anyone. Pick another. |
Exam is not available to this student. … | Rare. Use the available exams list, which never offers such an exam. |
Student already has an application for <category> on this sitting. Two exams in one category on one date cannot both be sat. | The student holds another exam in this category on this session. To change language, move the existing application instead of creating a new one. |
Only on a move. The first row is checked before anything else, and the others after the rules above:
| Message | Cause and fix |
|---|---|
This exam has already been started and can no longer be changed. | The student has started or submitted the exam. |
An application for <category> cannot be moved to that category. | The old exam's category does not allow a switch to the new exam's category. |
Training has already started for this AI Challenge application, so it can no longer be moved. | The student has used part of the AI Challenge's image quota. |
Application has been paid for at <amount>, and that exam costs <price>. … | A paid application can move only to an exam with the same price. |
Deleting an application
Application has been paid for and cannot be deleted. Cancelling a paid application requires a refund, which this API does not perform. The application has a settled payment. Contact the organization about a refund. The API cannot make one.
Status 413 and 415: payload_too_large and unsupported_media_type
- 413: the JSON body is larger than 100 kB. The request was refused before anything was read or written. No operation needs a body anywhere near that size, so look for a bug that sends too much, such as a whole student list in one request.
- 415: the body is not UTF-8, or it is compressed in a way the API does not read. Send
Content-Type: application/jsonin UTF-8, either uncompressed or withContent-Encoding: gzip,deflateorbr.
Status 429: too_many_requests
You made more than 100 requests to one operation within 60 seconds.
- Wait for the number of seconds in
Retry-Afterbefore sending that operation again. Requests sent sooner are refused too, but they do not extend the wait. - Other operations are unaffected. The limit is counted separately for each operation, so you can keep calling the others.
- All your servers share the budget. It belongs to the account, so spreading calls across servers or addresses does not raise it.
- Pace yourself. Read
X-RateLimit-RemainingandX-RateLimit-Reset(seconds until the window resets) on each response, and slow down before you reach zero. - Fix the loop. A request that fails validation still counts. A loop that retries a
400burns through your budget.
See Rate limits for budgeting patterns.
Status 500: internal_error, timeouts and dropped connections
500 internal_erroris a problem on our side. The message is alwaysAn unexpected error occurred., or a specific one such asCould not issue a sign-in token, please retry.Retry with exponential backoff and jitter, starting at about one second. If it keeps happening, send support therequest_id.- A timeout or dropped connection on a write leaves you unsure whether it happened. Before you repeat it, check:
- For a registration, send the same request again. If the first attempt went through, the repeat answers
409with the new student's id in the message. - For an application, simply repeat. The same student and exam return the existing application.
- For a move, fetch the application and look at its exam.
- For a registration, send the same request again. If the first attempt went through, the repeat answers
- A download that stops partway is a failed download, even if some bytes arrived. Discard the partial file and try again. Compare against
Content-Lengthwhen it is present.
Sign-in links that fail in the browser
These problems happen after your API call has succeeded, in the student's browser.
The student sees "Invalid or expired access token"
A link works once, and for 120 seconds. The student sees this message when:
- The link was already used. The student opened it twice, for example with a refresh or back button, or a second tab.
- Something opened it first. Chat and email link previews, security scanners and browser prefetching all open links, and any of them uses up the link. This is the most common hidden cause when you send links through a messaging tool.
- It was more than 120 seconds old. For example, you created it when the page loaded, and the student clicked later.
The fix for all three is the same. Create the link when the student clicks, and answer that click with a 302 redirect to the link. Never show the link on a page, send it, or store it. Send a student to the panel has complete Node.js and PHP examples.
The student lands on the home page instead of the page I chose
redirect was left out, so the organization's home page was used. Send a path such as /dashboard. An empty redirect never gets this far: it is refused with 400.
The panel keeps asking the student for an email code
The student's email address isn't confirmed. The panel asks for a 6-digit code on every page until it is, My Exams included, so the student can't start an exam before confirming. They can't put it off: the only way out is signing out. A student already inside an exam room is not interrupted. This is expected, and your API call played no part in it. Check, in order:
- The code didn't arrive. It comes from
no-reply@main-team.org, so ask the student to check their spam folder. A code is valid for 15 minutes, and they can ask for a new one after 60 seconds. - The address is wrong. A student can't receive a code at an address that isn't theirs. Correct it with
PUT /v1/student/{studentId}, then send the student a new sign-in link. A student who was already signed in when the address changed is told to sign in again before a code can be sent. - You changed the address. Changing a student's email through the API withdraws its confirmation, so the panel asks again, for the new address, once the student signs in again.
Only the confirmation on the student's core record counts. Read it as emailConfirmed with GET /v1/student/{studentId}. The sandbox's panels never ask for a code, because the sandbox sends no emails; see Environments. Have your students confirm well before an exam day, for example right after their first sign-in.
Creating the link is refused
Look up the answer:
403"Student is not activated…": see Other 403 messages.404 Student not found!: see 404 not_found.400onredirect: see Field messages.
An exam I expect is missing
Work outward from the student:
- Is the exam open? It must appear in
listExamsfor that organization. If it is not there, it is closed, and nothing else matters. - Does it accept the student's grade? The exam's
gradeslists the grades it accepts. Check the student'sgrade. - Does it accept the student's country? If its
countrieslist is not empty, it must include the student's country. The country is matched by name in the organization's own country list. If the organization has no country of that name, the student is offered only exams with no country restriction. - Does it have a language? An exam with no language is never offered.
- Does the student already hold this category on this session? A student cannot hold two exams in one category on one date. Move the existing application to change language.
The student does not need to have signed in to the organization for listAvailableExams to work. They do need to have signed in before you can apply.
A list comes back empty
| List | Why it can be empty |
|---|---|
listOrgStudents | Your students' activatedPlatformsThisSeason hold neither common nor this organization's slug. |
listApplications | Only your own students' applications are listed, and only for students who have access to this organization and have signed in to it at least once. |
listExamApplications | The same rule as listApplications, for one exam. An exam id from another organization matches nothing. |
listStudentCertificates, listStudentReports | Nothing has been released for this student yet. Documents appear only once the organization publishes results. |
| Any list | page is beyond pagination.totalPages. Past the last page, data is empty. |
A limit above 100 is lowered to 100, and a missing or unreadable page or limit falls back to the default. Neither is an error. See Pagination.
A certificate or report download answers 404
downloadCertificate and downloadReport answer 404 Not found! in each of these cases, and never say which:
- No certificate or report has this
_idorshortIdon this organization. - It is not released yet, or, for a report, it was withdrawn.
- It belongs to another account's student.
- It has no file attached, or the file is missing.
Take ids from the student's list for the same organization. The lists show only released documents, so an id from a list should download. If one does not, send support the request_id.
What to send support
Most problems can be solved from one request id. When you write to support, include:
| Include | Example |
|---|---|
The request_id from the error, or the X-Request-Id response header | 7c1e9a52-3f0b-4d8e-9a61-2b5d0c4e8f13 |
| The time of the request, in UTC | 2026-10-02 14:03:27 UTC |
| The operation, by its operationId, plus method and path | createApplication, POST /v1/64b7…c3d5/application |
The status, error.code and error.message you received | 409 conflict, "Exam is not available for grade 7. …" |
| What you expected, and what you already checked on this page | "Expected 201; the exam is in the available list for this student" |
Your company name and your apiKey | key_Q2x5… (the apiKey is public, so it is fine to share) |
Never include:
- your
apiSecret, - a token, including in a pasted
Authorizationheader or a curl command, - a sign-in link URL,
- a student's password.
A token or link is a working credential. If you have already sent one anywhere, revoke the token, and treat the link as used. Keep students' personal details out of your message too. Their ids are enough for us to find the records.
Note
Sending your own X-Request-Id with each request (letters, digits and . _ : ; = + / @ -, up to 256 characters) makes the id in our logs the same as the one in yours, so a single value finds the request on both sides. A value with any other character, or a longer one, is replaced with an id the API generates, and the X-Request-Id response header carries that one.