Skip to content
API documentation
View as MarkdownOpen in Claude

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 code say what kind of failure this is. Branch your code on these two, never on the message text, because messages can be reworded.
  • message is 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_id identifies this one request. It also comes back in the X-Request-Id header 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.

StepWhat is checkedRefusal
1The request body can be read413 payload_too_large, 415 unsupported_media_type, or 400 bad_request for malformed JSON
2The path exists404 not_found
3The token401 unauthorized
4The {organizationId} in the path, if there is one404 not_found, Organization not found!
5Your roles grant the operation403 forbidden, Insufficient role permissions
6The rate limit429 too_many_requests
7The body's fields400 bad_request
8The operation's own rules: the ids in the path, ownership, open exams, payments400, 403, 404 or 409, with a specific message

Consequences:

  • A request without a valid token gets 401 whatever 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.

  1. The header. It must be exactly Authorization: Bearer <token>: a capital B, one space, then the token, with no quotes and no line break. bearer, Token and a doubled space are all refused.
  2. kid is missing or wrong. The token header must carry kid with your apiKey exactly as issued: key_ followed by 24 characters. Many libraries add kid only when asked. In jsonwebtoken, use the keyid option.
  3. sub does not equal kid. The payload's sub must be your apiKey too.
  4. iat or exp is missing. Both are required, as whole seconds since the Unix epoch, not milliseconds. Some libraries set iat only when asked. In jose, call .setIssuedAt().
  5. The lifetime is too long. exp - iat may be at most 3600. expiresIn: '2h' or '1d' produces a token that is refused, even though it has not expired.
  6. Your clock is wrong. An iat more than 30 seconds in the future is refused. So is a token more than 30 seconds past its exp. Check the server with date -u and keep it synchronized with NTP.
  7. The signature. The token must be signed with HS256, using the whole apiSecret string, including its secret_ 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 the apiKey by mistake.
  8. The token was revoked, or it simply expired while a long job was running. Sign a new one.
  9. 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 401 right 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.

  1. 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-me returns them, provided you hold api/*.
  2. 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) needs mto or *.
  3. api/* is its own action. The two /v1/api-account operations need api/*, */* or *. A role for */read does not grant them.
  4. auth/signin is its own action. Sign-in links and passwords need it, and no student/* or api/* role implies it.
  5. A disallow role wins. If any matching role says disallow, the operation is refused, however many roles allow it.
  6. authorized must be empty or your own account id. A role whose authorized names a different id does nothing for your account.
  7. 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

MessageOperationCause and fix
Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.registerStudentYou 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>.createSigninLinkThe 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.

MessageWhat it meansWhat 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 pathThe 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.

linkStudentSupervisor answers the same 404 Not found! to every refusal, so the message never says which check failed. Check each of these:

  1. The student is yours. Use the student's core-record id.
  2. The student has signed in to this organization at least once, which creates the organization's copy of them.
  3. The username is spelled correctly. Leading and trailing spaces are removed and case does not matter. A username made only of spaces is a 404 too.
  4. 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, createdAt and updatedAt are read-only. Send only the fields you are changing.
  • password on an update. Passwords are set only at registration or with setStudentPassword.
  • Anything on an application besides examId (to move one) or besides studentId and examId (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 emailSend the required field with a valid value.
birth must be a real date in DD/MM/YYYY formatSend 14/05/2011, not 2011-05-14.
sex must be one of the following values: m, f, nUse one of the three codes.
country must be a mongodb idcountry takes only an _id from listCountries.
email2 must be emptyLeave 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 emptySend 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 addressOn 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.

MessageFix
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

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

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

MessageCause 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/json in UTF-8, either uncompressed or with Content-Encoding: gzip, deflate or br.

Status 429: too_many_requests

You made more than 100 requests to one operation within 60 seconds.

  1. Wait for the number of seconds in Retry-After before sending that operation again. Requests sent sooner are refused too, but they do not extend the wait.
  2. Other operations are unaffected. The limit is counted separately for each operation, so you can keep calling the others.
  3. All your servers share the budget. It belongs to the account, so spreading calls across servers or addresses does not raise it.
  4. Pace yourself. Read X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the window resets) on each response, and slow down before you reach zero.
  5. Fix the loop. A request that fails validation still counts. A loop that retries a 400 burns through your budget.

See Rate limits for budgeting patterns.

Status 500: internal_error, timeouts and dropped connections

  • 500 internal_error is a problem on our side. The message is always An unexpected error occurred., or a specific one such as Could 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 the request_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 409 with 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.
  • A download that stops partway is a failed download, even if some bytes arrived. Discard the partial file and try again. Compare against Content-Length when it is present.

See Retries and idempotency.

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:

  1. The link was already used. The student opened it twice, for example with a refresh or back button, or a second tab.
  2. 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.
  3. 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:

  1. 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.
  2. 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.
  3. 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.

Look up the answer:

An exam I expect is missing

Work outward from the student:

  1. Is the exam open? It must appear in listExams for that organization. If it is not there, it is closed, and nothing else matters.
  2. Does it accept the student's grade? The exam's grades lists the grades it accepts. Check the student's grade.
  3. Does it accept the student's country? If its countries list 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.
  4. Does it have a language? An exam with no language is never offered.
  5. 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

ListWhy it can be empty
listOrgStudentsYour students' activatedPlatformsThisSeason hold neither common nor this organization's slug.
listApplicationsOnly 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.
listExamApplicationsThe same rule as listApplications, for one exam. An exam id from another organization matches nothing.
listStudentCertificates, listStudentReportsNothing has been released for this student yet. Documents appear only once the organization publishes results.
Any listpage 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:

  1. No certificate or report has this _id or shortId on this organization.
  2. It is not released yet, or, for a report, it was withdrawn.
  3. It belongs to another account's student.
  4. 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:

IncludeExample
The request_id from the error, or the X-Request-Id response header7c1e9a52-3f0b-4d8e-9a61-2b5d0c4e8f13
The time of the request, in UTC2026-10-02 14:03:27 UTC
The operation, by its operationId, plus method and pathcreateApplication, POST /v1/64b7…c3d5/application
The status, error.code and error.message you received409 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 apiKeykey_Q2x5… (the apiKey is public, so it is fine to share)

Never include:

  • your apiSecret,
  • a token, including in a pasted Authorization header 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.

Endpoints covered

Search the API documentation

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