Skip to content
API documentation

Concepts

Errors

When the API refuses a request, it answers with an HTTP status and a JSON body of one fixed shape. The status and error.code tell your code what happened; the message is for the person reading the log.

The error body

{
  "error": {
    "code": "not_found",
    "message": "Not found!",
    "documentation_url": "https://hub.main-team.org/api/errors#not_found",
    "request_id": "0b5c6d0e-8f7a-4b1c-9d2e-3f4a5b6c7d8e",
    "details": {}
  }
}
FieldWhat it is
codeStable. One of the 12 codes below; together with the status it says what to do.
messageFor people. Operations word it their own way, and the wording can change in any release. Never compare it in code.
documentation_urlThe entry for the code on this page: https://hub.main-team.org/api/errors#<code>.
request_idThe id of this request. Every response, successful or not, carries the same value in its X-Request-Id header. Quote it when you ask for help.

You can choose the id yourself: send an X-Request-Id header of at most 256 characters, made of letters, digits and . _ : ; = + / @ -. The API uses it for that request and sends it back. Any other value is replaced with an id of the API's own.

Every error the API answers carries this body. A 502 or 504 without it came from the network in front of the API, and your request may not have reached it.

Branch on status and code

Decide what to do from the HTTP status and error.code together, never from the message. Some statuses carry more than one code: 400 is invalid_email or bad_request, so check both.

const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
if (!res.ok) {
  const { error } = await res.json().catch(() => ({}))
  if (res.status === 429 && error?.code === 'too_many_requests') {
    const seconds = Number(res.headers.get('Retry-After'))
    // wait that long, then send the same request again
  } else if (res.status === 404 && error?.code === 'not_found') {
    // the record does not exist, or it is not yours
  } else {
    // log res.status, error?.code and error?.request_id
  }
}
Every error code, its status and whether to retry
StatusCodeRetry
400invalid_emailRetry after a fix
400bad_requestRetry after a fix
401unauthorizedRetry after a fix
403forbiddenRetry after a fix
404not_foundDo not retry
409conflictRetry after a fix
413payload_too_largeRetry after a fix
415unsupported_media_typeRetry after a fix
422unprocessable_entityRetry after a fix
429too_many_requestsRetry after Retry-After
500internal_errorRetry with backoff
503service_unavailableRetry with backoff

Messages are for people

One code can carry several messages. Besides each code's default, the reference shows these. Use a message to decide what to check, never as a condition in code: the same refusal can be worded differently in the next release.

MessageStatus and codeOperations
birth must be a real date in DD/MM/YYYY format400 bad_request
country is not a known country.400 bad_request
email must be a valid email address400 bad_request
email must be given once, as one or more email addresses separated by commas400 bad_request
email must list at most 100 addresses400 bad_request
Invalid token format.400 bad_request
Invalid value for '_id': expected ObjectId.400 bad_request
18 operations
Invalid value for 'challengeId': expected ObjectId.400 bad_request
8 operations
Invalid value for 'exam': expected ObjectId.400 bad_request
Invalid value for 'groupId': expected ObjectId.400 bad_request
4 operations
Invalid value for 'mainId': expected ObjectId.400 bad_request
Invalid value for 'stepId': expected ObjectId.400 bad_request
Invalid value for 'studentId': expected ObjectId.400 bad_request
lastName should not be empty400 bad_request
4 operations
password must not contain the student's own name, username or email address400 bad_request
property password should not exist400 bad_request
redirect must be a site-relative path starting with "/" (e.g. "/dashboard")400 bad_request
school is not a known school. This API does not create reference data.400 bad_request
signedIn must be true or false400 bad_request
Student has no grade set, and every exam is restricted to a set of grades. Set one with PUT /:organizationId/student/:studentId before applying.400 bad_request
Student has no grade set, and every exam is restricted to a set of grades. Set one with PUT /:organizationId/student/:studentId before listing exams.400 bad_request
students must contain at least 30 elements400 bad_request
students.4.birth birth must be a real date in DD/MM/YYYY format400 bad_request
Insufficient role permissions403 forbidden
46 operations
Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.403 forbidden
Student is not activated for organization stem.403 forbidden
Application not found!404 not_found
Category not found!404 not_found
Country not found!404 not_found
Exam is not open for application, so it is not available through this API.404 not_found
Exam not found!404 not_found
Grade not found!404 not_found
Group challenge not found!404 not_found
8 operations
Group not found!404 not_found
4 operations
Import not found!404 not_found
Not found!404 not_found
Organization not found!404 not_found
31 operations
Step not found!404 not_found
Student not found!404 not_found
14 operations
A student with that email is already registered to this account (6650a1b2c3d4e5f6a7b8c9d0).409 conflict
Application has been paid for and cannot be deleted. Cancelling a paid application requires a refund, which this API does not perform.409 conflict
Every step has to be submitted before the group’s work can be sent.409 conflict
Exam has no language set, so it is not offered to students and cannot be applied to.409 conflict
Exam is not available to this student. Only exams returned by GET /:organizationId/exam/available/:studentId can be applied to.409 conflict
Exam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to.409 conflict
Nothing has been uploaded for this step yet. A member of the group uploads the work first.409 conflict
That email address is already registered.409 conflict
The group challenge takes no submissions outside its dates (windowStart to windowEnd).409 conflict
The group is still being prepared by its teacher, so it has no steps yet.409 conflict
The teacher has not confirmed the group yet, so it has no steps yet.409 conflict
This exam has already been started and can no longer be changed.409 conflict
This group challenge is closed.409 conflict
This step is not open yet: the steps before it come first.409 conflict
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.409 conflict
Training has already started for this AI Challenge application, so it can no longer be moved.409 conflict
You already have an import that has not finished. Read it, and send the next one when it has. (65f0c2a1d3e4f5a6b7c8d901)409 conflict
request entity too large413 payload_too_large
12 operations
unsupported charset "ISO-8859-1"415 unsupported_media_type
12 operations
3 of 250 rows cannot be registered. Nothing was registered and no import was queued; error.details.rows lists every problem.422 unprocessable_entity
Could not issue a sign-in token, please retry.500 internal_error
Bulk registration is paused for a scheduled window. Nothing was queued; send it again after the time in Retry-After.503 service_unavailable
Checking this batch took too long. Nothing was registered and no import was queued. Send it again later, or in smaller batches.503 service_unavailable
The group is being changed by someone else. Try again in a second.503 service_unavailable
This server is already checking another import. Send it again in a few seconds.503 service_unavailable

Reporting a problem

If an error goes on and you need help, send:

  • the request_id of a failing request (or its X-Request-Id header);
  • the time you sent it, in UTC;
  • the operation, by its name or its method and path, with the status and error.code you got.

Never send your API secret or a token. The request_id is enough to find the request on our side. Where to send it is on the Support page.

See also: Rate limits · Retries and idempotency · Troubleshooting

Error codes

One entry per code, in the order of the table above. Each says what causes it, how to fix it and whether to retry.

400invalid_email

Retry after a fix

An email address in the request is not a valid address. Reserved; no operation currently returns this code.

Default message
The email address provided is formatted incorrectly.
Retry
Change the request, or have your access changed, before you send it again.

Causes

invalid_email is part of the error catalog, but no operation currently returns it. When a student operation rejects an email address, it answers 400 bad_request with one of these messages:

MessageOperationsCause
email must be an emailregister a student, update a student, update an organization studentemail isn't a valid address.
email should not be emptyregister a student, update a student, update an organization studentemail is missing at registration, which requires one, or was sent as an empty string.
email must be a valid email addressupdate a student, update an organization studentemail was sent as null. An update can change the address but can't remove it.

If this code ever appears, it will look like this:

{
  "error": {
    "code": "invalid_email",
    "message": "The email address provided is formatted incorrectly.",
    "documentation_url": "https://hub.main-team.org/api/errors#invalid_email",
    "request_id": "0b5c6d0e-8f7a-4b1c-9d2e-3f4a5b6c7d8e"
  }
}

Fixes

  • Handle invalid_email the same way as a bad_request about the email field, so your client keeps working if the code starts being used.
  • Validate addresses in your own forms before you send them, and trim spaces around them.
  • The API stores addresses in lower case, and each address can belong to only one student on the whole platform. A valid address that is already taken answers 409 conflict, not 400.
  • Fix the address and send the request again. Sending it again unchanged gets the same answer.

No operation in the reference lists this code.

400bad_request

Retry after a fix

The body, a path parameter or a referenced value is invalid. The message names the first problem found.

Default message
The request was malformed or contained invalid parameters.
Retry
Change the request, or have your access changed, before you send it again.

Causes

A 400 means the API could not accept the request as it was sent. Nothing was changed. The message names the first problem found. If a body has several, fix that one and send it again to see the next.

{
  "error": {
    "code": "bad_request",
    "message": "property participated should not exist",
    "documentation_url": "https://hub.main-team.org/api/errors#bad_request",
    "request_id": "3a2b1c0d-9e8f-4a7b-b6c5-d4e3f2a1b0c9"
  }
}
A field the operation doesn't accept
MessageCause
property <name> should not existThe body carries a field the operation doesn't declare. The API refuses unknown fields rather than ignoring them, so a request never silently does less than you asked.

Some common examples:

  • password sent to PUT /v1/student/{studentId} or to the organization student update. Passwords have their own route.
  • studentId sent to the application move. The move takes only examId.
  • userType, roles, emailConfirmed, participated or any other field copied out of a record.
A value in the wrong format

The API checks every field it accepts. Typical messages:

MessageCause
email must be an emailemail isn't a valid address.
email should not be emptyemail is missing at registration, or is an empty string.
email must be a valid email addressAn update sent email as null. An update can change the address but can't remove it.
firstName should not be emptyfirstName is missing or empty at registration, or an update sent it empty or null.
lastName should not be emptyThe same, for lastName. It is required at registration too.
firstName must be a string, lastName must be a string, grade must be a stringThe field was sent as something other than a string, such as the number 9. The same message pattern applies to city and school.
birth must be a real date in DD/MM/YYYY formatbirth isn't DD/MM/YYYY, names a day that doesn't exist, such as 31/02/2010, or an update sent it as null. 2010-05-14 is refused. Use 14/05/2010. Registration and both student updates check it the same way.
sex must be one of the following values: m, f, nsex has another value, or an update sent it as null.
phone must be a stringphone isn't a string, or was sent as null. Leave it out instead, or on an update send "" to clear a student's phone number.
country must be a mongodb idcountry takes an id only, from GET /v1/country.
studentId must be a mongodb id, examId must be a mongodb idThe id isn't 24 hexadecimal characters.
email2 must be emptyemail2 must be left out or empty.
redirect must be a site-relative path starting with "/" (e.g. "/dashboard")A sign-in link's redirect is a full URL, starts with //, or contains a backslash or whitespace.
supervisorUsername should not be emptyThe supervisor link needs a username.
activatedPlatformsThisSeason must be an arrayactivatedPlatformsThisSeason isn't a list, or was sent as null, which registration and both updates refuse. Leave the field out instead: an update then keeps the student's list as it is, and registration stores ["common"].
each value in activatedPlatformsThisSeason must be a stringA value in activatedPlatformsThisSeason isn't a string.

A value in activatedPlatformsThisSeason that isn't common or one of the slugs stem, hilingua, neo, gmath and coding is refused with a message listing the values allowed. mto is refused too: every student is on the core record already.

A password that breaks the rules
MessageCause
password must be longer than or equal to 5 characters or password must be at least 5 characters longIt is shorter than 5 characters. Either message can come first.
password must be a stringIt isn't a string, for example a number.
password must be at most 72 bytes long, because bcrypt ignores anything past thatIt is longer than 72 bytes. Characters outside ASCII take more than one byte each.
password must not contain the student's own name, username or email addressIt contains the student's first name, surname, username, email address or the part of the address before the @. Details shorter than 4 characters aren't checked. At registration the password is compared with the firstName, lastName and email in the same body. On the password route it is compared with the student's stored record, username included.
Reference data that doesn't exist

The API resolves country, grade, city and school to existing records, and never creates new ones:

MessageCause
country is not a known country.No country has that id, or it is one of the countries that can't be selected.
grade is not a known grade.You gave grade as an id, and no grade has it. The same message pattern applies to city and school: city is not a known city., school is not a known school.
grade is not a known grade. This API does not create reference data.You gave grade as a name, and no grade has it. Grade names are 1 to 12. See GET /v1/grade.
city is not a known city. This API does not create reference data.No city has that name in the student's country.
school is not a known school. This API does not create reference data.No school has that name in the student's country and city.
city was given as a name, which can only be resolved together with country.You gave a city by name, and the API has no country to look it up in. The same message, starting school was given as a name, means a school name with no country or no city to look it up in. On an update, the student's stored country and city are used when the body leaves them out.
grade must be the id of a grade or its name.The value is only spaces. The same pattern applies to city and school.
country is not a known country, so no username can be issued for this student.The country can't be used to build the student's username.
An id in the path that isn't an id
MessageCause
Invalid value for '_id': expected ObjectId.A student, application, exam, country, grade, organization or category id in the path isn't 24 hexadecimal characters.
Invalid value for 'exam': expected ObjectId.The same, for {examId} in GET .../application/exam-applications/{examId}.
Invalid value for 'mainId': expected ObjectId.The same, for {studentId} in GET /v1/{organizationId}/student/{studentId}.

{organizationId} is the exception. A malformed organization id answers 404 Organization not found!.

A student record that isn't complete
MessageCause
Student has no grade set, and every exam is restricted to a set of grades. Set one with PUT /:organizationId/student/:studentId before listing exams.The exam picker for a student with no grade.
Student has no grade set, and every exam is restricted to a set of grades. Set one with PUT /:organizationId/student/:studentId before applying.Creating or moving an application for a student with no grade.
A body that isn't valid JSON

A body that can't be parsed as JSON is refused with a message from the JSON parser that says where parsing failed. So is a body sent with Content-Encoding: gzip, deflate or br that doesn't decompress. Both are checked before your token.

Other operation-specific cases
  • Unknown organization: <slug>: the organization exists, but the API doesn't serve its records.
  • No token provided. and Invalid token format. from revoke-token. A token that passed authentication always has what that operation needs, so you should never see these.
Not a 400
  • A missing or out-of-range page or limit is never refused. The API uses the default or clamps the value (see Pagination).
  • A body sent with the wrong Content-Type, such as text/plain, is ignored rather than refused. You then get the validation errors of an empty body, for example studentId must be a mongodb id. Send Content-Type: application/json.

Fixes

  1. Read the message. It names the field and the problem.
  2. Send only documented fields. Build each body from the fields on the operation's reference page, not from a whole record.
  3. Use the documented formats: DD/MM/YYYY for birth, m, f or n for sex, and 24-character hexadecimal ids.
  4. Resolve reference data first. Take country from GET /v1/country. Give grade as its id or its name (1 to 12). Give city and school as ids or as names that exist in the student's country.
  5. Complete the student (for example set a grade) before listing exams or applying.
  6. Send Content-Type: application/json with a UTF-8 body.
  7. Fix the problem and send the request again. Sending it again unchanged always gets the same 400.

Field-by-field rules are on the reference pages and in Students. Request formats in general are in Requests and responses.

Listed by 36 operations

The token could not be read back or has no exp. A token that passed authentication always can, so you should never see this; nothing was revoked.

email is empty, is given twice, or holds a value that is not an email address.

email lists more than 100 addresses.

country is not the _id of a country listCountries lists, or the country has no two-letter code to begin a username with.

grade, city or school matches nothing: no record has that _id, and none has that name (within country, and for a school within city too).

birth is not DD/MM/YYYY, or is not a date that exists, such as 31/02/2008.

password is shorter than 5 characters, longer than 72 bytes, or contains the student’s own name or email address.

password was sent. The check never takes one: leave it out, and send it with registerStudent only.

A row breaks one of registerStudent’s rules. The message names the row and the property, counting rows from 0.

students has fewer than 30 rows or more than 1000. Below 30, call registerStudent per student.

A row carries password, which this operation does not take, or any other property it does not accept.

importId is not 24 hexadecimal digits.

email is null: an address can be changed, not removed.

firstName, lastName, birth, sex, phone or activatedPlatformsThisSeason is null, or firstName or lastName is empty. These can be changed, not removed; clear phone with "".

country, grade, city or school matches nothing or is null, or a city or school name cannot be looked up because the student has no country, or no city for a school.

password contains the student’s first name, surname, username or email address, as stored.

signedIn is given, and is neither true nor false.

country, grade, city or school matches no record, by id or by name, or a city or school name was sent for a student with no country (or, for a school, no city) to look it up in.

email is null: an address can be changed, not removed. Any other value that is not an address is refused by the body’s rules ("email must be an email").

redirect is not a path on the panel: it needs one leading /, and no whitespace or backslash anywhere.

categoryId is not 24 hexadecimal digits.

The student has no grade. Every exam is restricted to a set of grades, so no exam could be offered; set one first.

The exam is open, but the student has no grade. Set one with updateOrgStudent or updateStudent, then try again.

401unauthorized

Retry after a fix

The token is missing, malformed, expired, revoked, or not signed with an active account's apiSecret.

Default message
Authentication is required or the provided credentials are invalid.
Retry
Change the request, or have your access changed, before you send it again.

Causes

Every operation except GET /v1/health needs a bearer token that you sign yourself (see Authentication). When the token fails any check, the answer is always the same:

HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
X-Request-Id: 7c1e9a52-3b8f-4d0e-9a41-2f6b8c0d5e17
{
  "error": {
    "code": "unauthorized",
    "message": "Authentication is required or the provided credentials are invalid.",
    "documentation_url": "https://hub.main-team.org/api/errors#unauthorized",
    "request_id": "7c1e9a52-3b8f-4d0e-9a41-2f6b8c0d5e17"
  }
}

The API never says which check failed, so that a leaked key or token tells its finder nothing. You have to find the cause yourself, from the list below. The token is checked before the organization in the path, your permissions and the fields in the request body. A request with a bad token therefore gets 401 whatever else is wrong with it. Only two things are checked earlier: whether the path exists (an unknown path answers 404 with no token at all), and the raw body, meaning its size, its encoding, and whether it is valid JSON.

The header
  • There is no Authorization header.
  • The header isn't exactly Bearer <token>: the word Bearer with a capital B, one space, then the token. bearer <token> and Bearer <token> (two spaces) are both refused.
  • The header carries your apiSecret or your apiKey instead of a signed token.
The token's header
  • The value is not a JWT (three base64url parts separated by dots).
  • There is no kid, or it is not your apiKey exactly: key_ followed by 24 characters. A common mistake is a library that puts the key in the payload but not in the header.
  • The algorithm is not HS256. HS512, RS256 and none are all refused.
The account
  • No active account has that apiKey. It has been deactivated, it was mistyped, or it was never issued. Changes an operator makes to your account take effect within 60 seconds.
The signature
  • The token wasn't signed with your apiSecret, or the secret was altered on the way. Typical cases: a trailing newline or quotes picked up from a configuration file, or a library that base64-decodes the key before using it. Sign with the whole string, including secret_, as UTF-8 bytes.
The claims
ClaimRefused when
subIt isn't your apiKey, the same value as kid.
iat, expEither is missing, or isn't a number of seconds.
iat, expThey are in milliseconds (JavaScript's Date.now()). An iat in milliseconds lies far in the future.
expIt isn't later than iat.
exp - iatIt is more than 3600 seconds (one hour).
iatIt is more than 30 seconds ahead of the API's clock. Your server's clock is fast.
expIt passed more than 30 seconds ago. The token has expired.
nbfYou don't need this claim. If you send it, the token is refused until 30 seconds before the time it names.
Revocation

Fixes

  1. Decode your token locally and compare it with the rules. Never paste a token into a website.
    // Prints what the API will check. Node.js, no dependencies.
    const [h, p] = token.split('.');
    const header = JSON.parse(Buffer.from(h, 'base64url').toString());
    const claims = JSON.parse(Buffer.from(p, 'base64url').toString());
    const now = Math.floor(Date.now() / 1000);
    console.log({
      algIsHS256: header.alg === 'HS256',
      kidIsKey: header.kid === process.env.MTO_API_KEY,
      subIsKey: claims.sub === process.env.MTO_API_KEY,
      lifetime: claims.exp - claims.iat, // must be 1..3600
      iatAheadBy: claims.iat - now, // must be <= 30
      secondsLeft: claims.exp - now, // must be > -30
    });
    
    <?php
    [$h, $p] = explode('.', $token);
    $decode = fn (string $part) => json_decode(base64_decode(strtr($part, '-_', '+/')), true);
    $header = $decode($h);
    $claims = $decode($p);
    $now = time();
    var_dump([
        'algIsHS256' => $header['alg'] === 'HS256',
        'kidIsKey' => $header['kid'] === getenv('MTO_API_KEY'),
        'subIsKey' => $claims['sub'] === getenv('MTO_API_KEY'),
        'lifetime' => $claims['exp'] - $claims['iat'],
        'iatAheadBy' => $claims['iat'] - $now,
        'secondsLeft' => $claims['exp'] - $now,
    ]);
    
  2. Keep your server's clock synchronized with NTP. Clock drift is the usual reason a token that worked yesterday fails today.
  3. Sign a fresh token and send the request once more. That covers an expired or revoked token. If the fresh token also gets 401, stop retrying: repeating the same token will not change the answer.
  4. Check that the account is still active. If every token fails, even a correctly built one, your account may have been deactivated. Contact us.
  5. Still stuck? Email info@main-team.org with the request_id and the UTC time of a failing request. The reason for the refusal is recorded against that id. Never send the token or your secret.

A 401 doesn't count against your rate limit. Token caching and clock handling are covered step by step in Token handling.

Listed by 46 operations

The token is missing or malformed, is not signed with your account’s apiSecret, breaks the iat and exp rules, has expired or been revoked, or its account is not active. All of these answer the same.

403forbidden

Retry after a fix

Your account lacks the permission this operation needs on this organization, or an access rule of the operation itself refused the request.

Default message
You do not have permission to access this resource.
Retry
Change the request, or have your access changed, before you send it again.

Causes

A 403 always means the API knows who you are: your token passed every check. What failed is whether your account may do this particular thing. There are three situations, and the message tells them apart.

{
  "error": {
    "code": "forbidden",
    "message": "Insufficient role permissions",
    "documentation_url": "https://hub.main-team.org/api/errors#forbidden",
    "request_id": "c2a4f0e1-9d3b-4b8e-a6f7-51e0d2c9b813"
  }
}
A missing permission: Insufficient role permissions

Every operation needs a permission, and your account holds permissions as roles (see Permissions). The request is allowed only when both of these hold:

  • An allow role matches all three of the following:
    • its action grants the operation's action, directly or through a wildcard;
    • its target is * or the organization the request acts on. That is the organization in the path, or mto for routes without {organizationId};
    • its authorized is empty or your account's own id.
  • No disallow role matches the same three.

The usual reasons a request is refused:

You calledYour account holdsWhy it's refused
GET /v1/studentallow student/* on stemRoutes without {organizationId} act on mto. The role needs target mto or *.
POST /v1/{organizationId}/auth/signin, with stem's _idallow student/* on stemauth/signin is its own action. No student/* or api/* role includes it.
PUT /v1/student/{studentId}/passwordallow auth/signin on stemThe password route has no {organizationId}, so it needs auth/signin on mto.
GET /v1/api-account/validate-meallow */read on *The account routes need api/*. Only api/*, */* or * grant it.
DELETE /v1/{organizationId}/application/{applicationId}, with stem's _idallow application/* on *, disallow application/delete on *A matching disallow always wins.
GET /v1/{organizationId}/exam, with neo's _idallow exam/read on stemThe role covers stem only.
Anythinga role whose authorized is another account's idThat role applies to nobody.

A refusal for a missing permission comes before the operation does anything, and it doesn't count against your rate limit.

A password at registration without auth/signin
Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.

POST /v1/student needs student/create. If the body also carries a password, the account needs auth/signin on mto as well, because a password you set is a sign-in as the student. The request is refused before anything is written.

Student is not activated for organization stem.

POST /v1/{organizationId}/auth/signin only mints a link into an organization the student has access to. That means their activatedPlatformsThisSeason holds common (every organization) or that organization's slug. A student registered without activatedPlatformsThisSeason gets ["common"], so this only happens to students whose list you set yourself. Nothing is written for a refused request.

What is not a 403
  • A bad or expired token is 401 unauthorized.
  • A record that belongs to another account is 404 not_found. The API never confirms that someone else's record exists.
  • An unconfirmed email address is not a reason for any 403. A sign-in link is issued either way.

Fixes

For Insufficient role permissions
  1. Work out which permission the operation needs, and on which organization, from the operation's page in the reference or the permission matrix.
  2. Compare it with the roles your account holds. GET /v1/api-account/validate-me lists them, if your account holds api/*.
  3. Ask for the missing role at info@main-team.org. Name the operation and the organization, and quote a request_id. Only an operator can change roles, and a change takes effect within 60 seconds.

Re-signing your token or retrying will not help. The same request keeps getting 403 until the role changes.

For a password at registration

Either ask for auth/signin on mto, or register the student without a password and send them into the panel with a sign-in link. Links are the safer choice anyway (see Security).

For a student without access to the organization

Give the student access with PUT /v1/{organizationId}/student/{studentId}, then mint the link again. An empty body adds this organization to the student's list and changes nothing else. Here the student was on ["neo"], and $ORGANIZATION_ID is stem's:

curl -X PUT "https://api.main-team.org/v1/$ORGANIZATION_ID/student/652f1c9b8e4b2a0012a3c4d5" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{}'
{
  "success": true,
  "message": "Student updated successfully.",
  "data": {
    "_id": "652f1c9b8e4b2a0012a3c4d5",
    "username": "XXB1047",
    "firstName": "Lena",
    "lastName": "Hoffmann",
    "fullName": "Lena Hoffmann",
    "email": "lena.hoffmann@example.com",
    "emailConfirmed": false,
    "phone": "+49 30 1234567",
    "birth": "14/05/2010",
    "sex": "f",
    "country": "630e0182c53dc79a6836e67e",
    "city": "630e0190c53dc79a6836f101",
    "school": "630e01a4c53dc79a68370a2b",
    "grade": "630e01826836e67ec53dc7a6",
    "activatedPlatformsThisSeason": ["neo", "stem"],
    "createdAt": "2026-09-01T08:12:44.302Z",
    "updatedAt": "2026-09-15T10:02:09.118Z"
  }
}

This needs student/update on that organization. The update only ever adds: the organizations already on the list stay, and a list that already holds common is left as it is. If you send activatedPlatformsThisSeason, the values in it are added instead, so name this organization in it.

Listed by 46 operations

The token is valid, but no role on your account allows api/* on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows country/read on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows grade/read on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows organization/read on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows student/read on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows student/create on mto, the organization every operation without :organizationId acts on, or a role denies it.

password was sent, and your account does not hold auth/signin on mto. Nothing was written.

The token is valid, but no role on your account allows student/update on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows auth/signin on mto, the organization every operation without :organizationId acts on, or a role denies it.

The token is valid, but no role on your account allows student/read on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows student/update on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows auth/signin on the organization in the path, or a role denies it.

The student has no access to this organization: their activatedPlatformsThisSeason holds neither its slug nor common. Grant it with updateOrgStudent, then ask again.

The token is valid, but no role on your account allows exam/read on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows exam-category/read on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows application/create on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows application/update on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows application/delete on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows certificate/read on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows report/read on the organization in the path, or a role denies it.

The token is valid, but no role on your account allows group-challenge/submit on the organization in the path, or a role denies it.

404not_found

Do not retry

The path, the organization or the record doesn't exist, or it belongs to another account, which the API never distinguishes from missing.

Default message
The requested resource could not be found.
Retry
Sending the same request again gets the same answer.

Causes

A 404 means the API has nothing to show you at this address. For records this is deliberate in one important way. A record that belongs to another API account answers exactly like a record that doesn't exist, so no account can find out what another account holds.

{
  "error": {
    "code": "not_found",
    "message": "Student not found!",
    "documentation_url": "https://hub.main-team.org/api/errors#not_found",
    "request_id": "5d0f3b6a-1c2e-4f7a-8b9d-0e1f2a3b4c5d"
  }
}

The message differs from operation to operation, and some operations use one message for several causes. Branch on the status and code, and use the message only to decide what to check.

The path
MessageCause
Cannot GET /v1/studentsNo operation has that path and method: a typo, a missing /v1, or the wrong method (for example PATCH, which the API doesn't use). This answer needs no token.
The organization
MessageCause
Organization not found!{organizationId} isn't 24 hexadecimal characters, or no organization that GET /v1/organization lists has that id (mto, the core record, isn't in that list). The most common mistake is putting the slug (stem) in the path instead of the organization's _id from GET /v1/organization.

The organization is checked after your token, so a request with an invalid token gets 401 whatever the id. An unknown organization doesn't count against your rate limit.

A student
MessageOperationsCause
Student not found!get or update a student, get or update an organization student, set a password, create a sign-in link, create an application, the exam picker, list a student's applications, certificates or reportsNo student with that id belongs to your account. Either it doesn't exist, or another account registered it. These operations take the student's core _id, the one registration gave you. Reading a student through an organization also answers this for a student of yours without access to that organization.
MessageCause
Not found!Linking a supervisor answers every refusal the same way. The possible causes: the student isn't yours; the student hasn't signed in to this organization yet, so it holds no copy of them; the username is blank; no user of that organization has the username; or its owner isn't a supervisor.
Applications
MessageOperationsCause
Application not found!get, move or delete an applicationNo application of your students has that id in this organization: it doesn't exist there, or another account's student holds it, and the two answer alike. Each organization keeps its own applications, so an id from stem is unknown on neo. A second DELETE of the same application also gets this.
Exams
MessageOperationsCause
Exam not found!get an exam, create or move an applicationNo exam with that id exists in this organization. Exams belong to one organization.
Exam is not open for application, so it is not available through this API.get an examThe exam exists but isn't open: applications are closed, its sitting has passed, or its category isn't active. Creating an application for it answers 409 instead.
Certificates and reports
MessageOperationsCause
Not found!download a certificate or reportEvery refusal looks the same: no document has that _id or shortId in this organization; it hasn't been released yet; a report has been withdrawn; it belongs to another account's student; it has no file attached; or its file is missing from storage.
Reference data and exam categories
MessageOperationsCause
Country not found!get a countryNo country has that id, or it is one of the countries that can't be selected.
Grade not found!get a gradeNo grade has that id.
Organization not found!get an organizationNo organization in GET /v1/organization has that id. mto, the core record, isn't in that list.
Category not found!get an exam categoryNo exam category has that id in this organization.
Not a 404
  • An id that isn't 24 hexadecimal characters is 400 bad_request, for example Invalid value for '_id': expected ObjectId. Two exceptions: {organizationId} answers 404 Organization not found!, and the download routes treat anything that isn't an id as a shortId.
  • An empty list is never a 404. It is 200 with "data": [] and a total of 0.

Fixes

  1. Check the path against the reference: the /v1 prefix, the spelling, and the method.
  2. Use the organization's _id, not its slug. Get it from GET /v1/organization and store it.
  3. Use the right id for the student. Operations take the core _id you got at registration. An _id you read off an application, a certificate or another organization's data is that organization's own id for the student. Use its mainId instead. See Identifiers.
  4. Use the organization the record lives in. Applications, exams, certificates and reports belong to one organization. Call the routes of that organization.
  5. Check which account you are using. Records belong to the account that registered the student. A second account, even your own, cannot see them.
  6. For the supervisor link, make sure the student has followed a sign-in link into this organization at least once, then check the username with the supervisor. See Supervisors.
  7. For downloads, list the student's documents first (certificates, reports) and download only what the list returns. A document that hasn't been released yet, or a report that has been withdrawn, is neither listed nor served. See Certificates and reports.

Don't retry a 404 unchanged: the same request gets the same answer. The one exception is a DELETE you retried after a timeout, where a 404 usually means your first attempt worked (see Retries and idempotency).

Listed by 38 operations

No country listCountries lists has this _id: it matches no country, or it names one of the reserved ones.

No grade has this _id.

No organization listOrganizations lists has this _id.

Bulk registration is not switched on for the environment you are calling. Nothing was queued. Ask support before you build against it.

No import of yours has this importId, it has expired, or bulk registration is not switched on for the environment you are calling. An import another account sent answers the same.

No student of yours has this studentId. A student another account registered answers the same.

organizationId is not the _id of an organization.

No student of yours with this id can use this organization: unknown, another account’s, or not activated here.

No student of yours has this studentId. A student registered by another account answers the same.

Any refusal: the student is not yours, this organization has no record of them yet (they have never signed in to it), or no supervisor of this organization has this username.

categoryId names no category in this organization.

No student of yours has this id: it is nobody’s, or another account’s. Both answer the same.

No exam in the organization has this id.

The exam exists but is not open for applications: it is closed to applications, its sitting has started or passed, or its category is inactive.

No application of your students has this id: it matches nothing, or the application belongs to another account’s student. Both get the same answer.

No certificate has this id or shortId, it is not released, it belongs to another account's student, it has no file attached, or its file is missing from storage. Every one of these answers the same, so the status says nothing about another account's ids.

No report has this id or shortId, it is not released, it belongs to another account's student, it has no file attached, or its file is missing from storage. Every one of these answers the same, so the status says nothing about another account's ids.

No group of this challenge has this id that one of your students is an active member of: a missing group, a deleted one and another account’s get the same answer.

No group of this challenge has this id with the student as an active member: a missing group, a deleted one and one the student is not in get the same answer.

The group has no step with this id. A group has its steps once the teacher confirms it.

409conflict

Retry after a fix

A duplicate email, a student who hasn't signed in to the organization yet, an exam the student can't take, an application whose state blocks the change, or a group challenge step or group that can't be submitted yet.

Default message
The request conflicts with the current state of the resource.
Retry
Change the request, or have your access changed, before you send it again.

Causes

A 409 means your request is well-formed and allowed, but something about the current state of the data blocks it. Nothing was changed. Every 409 message names the state that blocked the request, so read it before deciding what to do.

{
  "error": {
    "code": "conflict",
    "message": "Exam is not available for grade 11. It accepts grade 9, 10.",
    "documentation_url": "https://hub.main-team.org/api/errors#conflict",
    "request_id": "9e8d7c6b-5a4f-4e3d-8c2b-1a0f9e8d7c6b"
  }
}
Email addresses
MessageOperationsCause
A student with that email is already registered to this account (652f1c9b8e4b2a0012a3c4d5).register a studentYou have already registered a student with this address. The id in parentheses is theirs.
That email address is already registered.register a student, update a student, update an organization studentAnother student already has this address. Email addresses are unique across the whole platform, and the API doesn't say whose it is. At registration this means another account holds it, or another registration of the same new address, sent at the same moment, finished first. On an update it can also be another of your own students.
Bulk registration
MessageOperationsCause
You already have an import that has not finished. Read it, and send the next one when it has. (65f0c2a1d3e4f5a6b7c8d901)register many students at onceOne import per account runs at a time. The id in parentheses is the one that is running: read it with getStudentImport, and send the next batch when it has finished. Sending the same rows again is not a conflict — it answers 202 with the import you already have. See Bulk registration.
Passwords
MessageOperationsCause
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.set a passwordOnce a student has confirmed their address, the account is theirs. A password set over theirs would lock them out.
A student who hasn't signed in to the organization yet
MessageOperations
Student has never signed in to stem, so stem holds no record for them. Generate a sign-in link first with POST /:organizationId/auth/signin.create an application, list a student's applications, list a student's certificates, list a student's reports

Each organization keeps its own copy of a student, and the student's first sign-in to that organization creates it. A student you have only registered through the API has no copy yet, so there is nothing in that organization to attach an application to. (The exam picker works without a copy. Linking a supervisor answers 404 instead.)

Exams a student can't take

POST /v1/{organizationId}/application and PUT /v1/{organizationId}/application/{applicationId} only accept an exam that the exam picker would offer this student. When it wouldn't, the message names the first rule that refused it:

MessageCause
Exam is not open for application. Only exams returned by GET /:organizationId/exam can be applied to.Applications are closed, the sitting has passed, or the exam's category isn't active.
Exam is not available for grade 11. It accepts grade 9, 10.The student's grade isn't one the exam accepts. An exam with no grades says It accepts no grades.
Exam is not available in this student’s country. It is offered in Germany, Austria.The exam is limited to certain countries, and the student's isn't one of them. If none of those countries can be named, the message ends It is offered in no country this API can resolve.
Exam has no language set, so it is not offered to students and cannot be applied to.No student can pick this exam.
Exam is not available to this student. Only exams returned by GET /:organizationId/exam/available/:studentId can be applied to.The picker withholds it for another reason.
Student already has an application for Science on this sitting. Two exams in one category on one date cannot both be sat.The student already holds a different exam in the same category on the same sitting. Nobody can sit two papers at once.

A student with no grade at all gets 400 bad_request instead, because the student record is incomplete.

Moving an application

Besides the exam rules above, a move is refused when:

MessageCause
This exam has already been started and can no longer be changed.The student has started or handed in the exam.
An application for Science cannot be moved to that category.The current exam's category doesn't accept the new exam's category as a replacement.
Training has already started for this AI Challenge application, so it can no longer be moved.The application is for an AI Challenge exam, and the student has already used some of their AI Challenge image quota. This applies whether or not the application has been paid for.
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.The application has a settled payment, and the new exam has a different price. A paid application can move to an exam with the same price.

The checks run in this order: started, then the exam exists, then the exam rules, then the category replacement, then the sitting clash, then AI Challenge, then the price.

Group challenge submits

submitGroupChallengeStep and submitGroupChallengeWork say why they refused in error.details.reason. Branch on it, not on the message:

{
  "error": {
    "code": "conflict",
    "message": "Nothing has been uploaded for this step yet. A member of the group uploads the work first.",
    "documentation_url": "https://hub.main-team.org/api/errors#conflict",
    "request_id": "3c2b1a0f-9e8d-4c7b-a6f5-e4d3c2b1a0f9",
    "details": { "reason": "step_empty" }
  }
}
details.reasonCause
challenge_closedThe organizers have closed the challenge.
window_closedNow is outside the challenge's dates; details also carries windowStart and windowEnd.
payment_pending, group_not_confirmedThe teacher has not confirmed the group yet, so it has no steps.
step_lockedThe step before this one is not submitted yet.
step_emptyNothing has been uploaded for the step.
steps_incompleteA step is not submitted yet; details counts stepsSubmitted and stepCount.

Submitting again something that was already submitted is not a conflict: it answers 200 with changed: false. See Group challenges.

Deleting an application
MessageCause
Application has been paid for and cannot be deleted. Cancelling a paid application requires a refund, which this API does not perform.A payment has been settled for this application. An application for a free exam counts as unpaid and can be deleted.

Fixes

A 409 never goes away if you send the same request unchanged. Change the state or the request first.

You gotDo this
Email already registered to this accountDon't register again. Use the id in the message: fetch the student with GET /v1/student/{studentId}, or update them. After a timed-out registration, this is how you learn it worked (see Retries).
That email address is already registered.At registration, check first that you didn't send the same registration twice at once. If you didn't, the address belongs to another account and can't be used, so ask the student for another one. On an update, check whether one of your own students already has the address.
Student has confirmed their emailDon't set a password. Send the student a sign-in link.
Student never signed in to the organizationMint a sign-in link with POST /v1/{organizationId}/auth/signin, have the student follow it once, then retry.
Exam not open, or not for this studentPick an exam from GET /v1/{organizationId}/exam/available/{studentId} and post a leaf's matchedExam._id. Every exam that list returns passes these rules.
Wrong grade or countryIf the student's record is wrong, correct it with PUT /v1/{organizationId}/student/{studentId}, then check the picker again.
Category clash on a sittingMove the existing application instead of creating a second one, or choose another sitting.
Started exam, AI ChallengeThe application can't be moved.
Category replacement refusedMoves from this category to that one aren't allowed. Choose an exam in a category the current one accepts.
Different price on a paid applicationChoose an exam with the same price, or contact the organization about a refund. The API cannot charge or refund.
Paid application can't be deletedThe API cannot refund. Contact the organization.
A group challenge submit, step_empty or steps_incompleteWait for the group's members to upload and submit their work in the panel or the app; read the group (canSubmit, canFinalSubmit) before trying again. Don't retry in a loop.
A group challenge submit, any other reasonThe challenge or the group is not ready, or no longer takes work. Nothing you send changes that.

A 409 on a retried create may mean the create worked. If a POST /application timed out and the retry answers 409 because the exam has since closed, the first attempt may still have created the application. Check the student's applications before you give up. See Retries and idempotency. The full application rules are in Applications.

Listed by 14 operations

One of your students already has this email address. The message ends with that student’s _id.

Another account registered this email address. The message does not say whose it is.

You already have an import that has not finished. Read it, and send the next one when it has.

email is changed to an address already registered, by your account or another. The message does not say whose it is.

The student has confirmed their email address, so the password is theirs to change.

email is already another student’s address. The answer does not say whose.

The exam is not open: applications to it are switched off, its sitting date has passed, or its category is inactive.

The exam does not accept the student’s grade. The message names the grades it does accept, such as "Exam is not available for grade 8. It accepts grade 9, 10."

The exam is restricted to countries the student is not in. The message names the countries it is offered in.

For any other reason, listAvailableExams leaves the exam out for this student.

The student already holds another exam in the same category on the same sitting, and nobody can sit both. Move that application (moveApplication) instead.

The student has started or handed in the exam. Their answers belong to its questions, so the application stays where it is.

The category of the application’s current exam does not accept the new exam’s category as a replacement. The message names the current category.

The current exam is in the AI Challenge category and the student has already used some of their image quota.

The payment is settled and the new exam costs something different. This API neither charges a difference nor refunds; the message names both figures.

The payment is settled. Cancelling a paid application is a refund, which happens outside this API.

The student has never signed in to this organization, so it holds no record of them.

The teacher has not confirmed the group yet (status: draft), so it has no steps.

A step is not submitted yet. details counts them.

The step is still locked: the steps before it are not all submitted.

Nothing has been uploaded for the step yet. A member uploads the work in the panel or the app first; retrying does not help.

413payload_too_large

Retry after a fix

The request body is over the limit: 100 kB, or 1.5 MB on createStudentImport, which takes a list of students.

Default message
The request body is larger than the API accepts.
Retry
Change the request, or have your access changed, before you send it again.

Causes

The API accepts JSON request bodies up to 100 kB (102,400 bytes), with one exception: createStudentImport takes up to 1.5 MB, because a list of 1000 students does not fit in 100 kB. A larger body is refused before anything else happens, before your token is even checked:

HTTP/1.1 413 Payload Too Large
Content-Type: application/json; charset=utf-8
X-Request-Id: 4f3e2d1c-0b9a-4876-a543-210fedcba987
{
  "error": {
    "code": "payload_too_large",
    "message": "request entity too large",
    "documentation_url": "https://hub.main-team.org/api/errors#payload_too_large",
    "request_id": "4f3e2d1c-0b9a-4876-a543-210fedcba987"
  }
}

No operation but the import needs anything near 100 kB. The largest body the rest of the API takes is one student registration, a dozen short fields. A 413 almost always means the body contains something it shouldn't:

  • a whole record, or several, copied out of your own system;
  • a file or an image encoded as base64 (no operation accepts files);
  • a list of students sent to an operation that handles one student at a time — see Bulk registration for the one that takes a list;
  • a bug that repeats or nests data.

A realistic import row is 250 to 450 bytes, so 1000 of them is about 0.45 MB: a 413 on createStudentImport means something other than the students is in the body.

Because the body is refused before authentication, a 413 tells you nothing about your token or permissions, and nothing was read or written.

Fixes

  • Send only the documented fields for the operation. A body with extra fields would be refused with 400 bad_request even if it were small.
  • Send one request per student, application or change. There are no bulk operations. See Rate limits for how to pace a batch.
  • Log the size of the body you are about to send, and look for the field that makes it large.

Sending the same body again always gets the same answer.

Listed by 12 operations

The body is larger than 1.5 MB.

415unsupported_media_type

Retry after a fix

The request body's charset isn't UTF-8, or its Content-Encoding isn't gzip, deflate or br.

Default message
The request body is in a character set or encoding the API does not read.
Retry
Change the request, or have your access changed, before you send it again.

Causes

The API reads JSON bodies in UTF-8. You can send them uncompressed, or compressed with gzip, deflate or br. A body it can't decode is refused before your token is checked:

{
  "error": {
    "code": "unsupported_media_type",
    "message": "unsupported charset \"ISO-8859-1\"",
    "documentation_url": "https://hub.main-team.org/api/errors#unsupported_media_type",
    "request_id": "8b7a6c5d-4e3f-4a2b-9c1d-0e9f8a7b6c5d"
  }
}
MessageCause
unsupported charset "ISO-8859-1"The Content-Type header names a charset the API doesn't read, for example application/json; charset=iso-8859-1 or windows-1252. The charset name must start with utf-: charset=utf8, without the hyphen, is refused as unsupported charset "UTF8".
unsupported content encoding "zstd"The Content-Encoding header names a compression the API doesn't read. Only gzip, deflate and br are accepted, or no encoding at all.

Nothing was read or written.

Not a 415

A body sent with a different content type, such as text/plain or application/x-www-form-urlencoded, is not refused with 415. The API doesn't read it at all, and you get the validation errors of an empty body instead, such as 400 studentId must be a mongodb id. If every field seems to be "missing", check your Content-Type first.

Fixes

  • Send Content-Type: application/json. Adding ; charset=utf-8 is optional. If you add it, spell it utf-8 with the hyphen.
  • Encode the body as UTF-8. Most JSON libraries do this by default. In PHP, make sure your strings are UTF-8 before calling json_encode. It fails on invalid UTF-8 and returns false, so use JSON_THROW_ON_ERROR.
  • Leave the body uncompressed, or use gzip. Bodies are small, so compression gains little.
curl -X POST "https://api.main-team.org/v1/$ORGANIZATION_ID/auth/signin" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json; charset=utf-8" \
  -d '{"studentId":"652f1c9b8e4b2a0012a3c4d5","redirect":"/dashboard"}'

Sending the same request again always gets the same answer.

Listed by 12 operations

422unprocessable_entity

Retry after a fix

One or more rows of a bulk student import cannot be registered. Nothing was queued; error.details.rows names every row at fault.

Default message
The request was well-formed but could not be processed.
Retry
Change the request, or have your access changed, before you send it again.

Causes

One operation answers 422: createStudentImport, when the batch is well-formed but one or more of its rows cannot be registered. Nothing was queued and nothing was registered.

It is the one refusal on this API that cannot be one sentence, so it carries error.details:

{
  "error": {
    "code": "unprocessable_entity",
    "message": "3 of 250 rows cannot be registered. Nothing was registered and no import was queued; error.details.rows lists every problem.",
    "documentation_url": "https://hub.main-team.org/api/errors#unprocessable_entity",
    "request_id": "e1d2c3b4-a5f6-4e7d-8c9b-0a1f2e3d4c5b",
    "details": {
      "total": 250,
      "rejected": 3,
      "truncated": false,
      "rows": [
        { "row": 4, "field": "birth", "code": "invalid_field", "message": "birth must be a real date in DD/MM/YYYY format" },
        { "row": 17, "field": "email", "code": "email_taken_by_your_student", "message": "One of your students already has this email address.", "studentId": "652f1c9b8e4b2a0012a3c4d5" },
        { "row": 31, "field": "email", "code": "duplicate_in_request", "message": "Row 12 has the same email address.", "duplicateOf": 12 }
      ]
    }
  }
}

row counts from 0, so it is the index in the students array you sent. Each row's code is one of invalid_field, unexpected_field, unknown_reference, duplicate_in_request, email_taken_by_your_student and email_unavailable; Bulk registration explains each of them.

The other kinds of problem are reported with other codes, as they always were:

The problemWhat you get
A value in the request is invalid: wrong format, unknown field, reference data that doesn't exist, a student without a grade400 bad_request
The request is valid, but the current state of a record blocks it: a duplicate email on a single registration, a closed exam, a paid application409 conflict

Fixes

  • Read error.details.rows, fix every row it names, and send the whole batch again. One round trip tells you everything; there is nothing to undo, because nothing was registered.
  • Don't retry it unchanged: the same rows will be refused the same way.
  • truncated: true means rows is shorter than rejected. Fix what is listed and send it again to see the rest.
  • rows can be empty with rejected counted, when the only problem is addresses that cannot be registered and your account has already been shown those rows several times that day.
  • Keep a default branch for unknown error codes in your client, keyed on the status class (see Versioning), and ignore a details you do not recognise.
Listed by 1 operation

One or more rows cannot be registered. Nothing was queued. error.details.rows lists every row at fault with a code to branch on: invalid_field, unexpected_field, unknown_reference, duplicate_in_request, email_taken_by_your_student (with that student’s _id) and email_unavailable, which never says who holds the address. The list is left out, and only counted, when the only problem is unavailable addresses and your account has already been shown those rows several times today.

429too_many_requests

Retry after Retry-After

Your account used up this operation's budget: 100 requests per 60 seconds, or 10 an hour on createStudentImport. Wait Retry-After.

Default message
Too many requests to this operation. Wait the number of seconds in Retry-After, then try again.
Retry
Wait the number of seconds in the Retry-After header, then send the request again.

Causes

Each API account may make 100 requests per 60 seconds to each operation. The 101st request to an operation within one window is refused.

One operation has a budget of its own: createStudentImport takes 10 requests an hour, because one request to it registers up to 1000 students. Its 429 reads the same way; only the numbers differ. See Bulk registration.

HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json; charset=utf-8
X-Request-Id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
{
  "error": {
    "code": "too_many_requests",
    "message": "Too many requests to this operation. Wait the number of seconds in Retry-After, then try again.",
    "documentation_url": "https://hub.main-team.org/api/errors#too_many_requests",
    "request_id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f"
  }
}

The message says what to do. Its wording may change, so rely on the status and code, never on the message.

How the limit is counted:

  • Per account. Calls from all your servers share one budget. Spreading a batch over more servers or addresses doesn't raise it.
  • Per operation. Each operation has its own budget, so listing students and reading one student are counted separately, and a burst on one operation doesn't slow the others. All calls to one operation share its budget, whatever ids are in the path. That includes calls to the same operation on different organizations. Two operations never share a budget, even when they do similar work, such as listing your students and listing an organization's students.
  • In fixed windows. A window starts with its first counted request and lasts 60 seconds.
  • After the limit, the operation is closed for 60 seconds. The block starts with the request that went over the limit and lasts a full 60 seconds, even if the window had only a few seconds left. So the first 429 says Retry-After: 60, and later ones give the seconds left in the block. Requests you send during the block are refused too. They aren't counted, and they don't extend it.

Not counted at all:

  • requests refused for authentication (401), for a missing permission (403) or for an unknown organization (404 Organization not found!);
  • requests to a path that doesn't exist;
  • GET /v1/health, which no account's budget covers (the per-address limit below still applies).

A request the operation itself refuses, such as a 400 for a field, a 404 for a record or a 409, has passed these checks, so it is counted. A body refused before your token is checked (a 413, a 415 or malformed JSON) is not.

A 429 without the JSON envelope

The network in front of the API also limits how fast each client address may send requests. It counts every request from that address, with or without a token, and it is separate from your account's budget. A request refused there gets a 429 that doesn't carry the JSON error envelope above and has no request_id. Wait about 10 seconds, then continue at a steadier pace. Spread a large batch evenly over time instead of sending it in bursts.

Every counted response carries headers that tell you where you stand, so you can slow down before you hit the limit:

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 18
HeaderMeaning
X-RateLimit-LimitRequests allowed per window on this operation.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetSeconds until the current window ends.
Retry-AfterOn a 429 only: seconds to wait before sending to this operation again. The 429 itself carries no X-RateLimit-* headers.

Fixes

  1. Wait at least Retry-After seconds, then send the request again. A 429 is refused before the operation runs, so resending is safe for every operation, including registration.
  2. Pace yourself with X-RateLimit-Remaining. When it gets low, pause until X-RateLimit-Reset seconds have passed rather than running into the limit.
  3. Make fewer calls:
    • Page with limit=100 (the maximum) instead of the default 20. That is five times fewer calls for the same list.
    • Cache reference data, such as countries, grades, organizations and exam categories. It rarely changes.
    • Don't poll. Fetch results on a schedule, for example a nightly job (see Collect results).
  4. Throttle on your side. Keep a simple counter per operation. Don't send more than 100 requests to one operation in 60 seconds from all of your workers combined, across every organization.
// Wait out a 429, honoring Retry-After. Other statuses are returned to the caller.
async function withRateLimit(send) {
  for (;;) {
    const res = await send();
    if (res.status !== 429) return res;
    const seconds = Number(res.headers.get('retry-after') ?? '60');
    await new Promise((resolve) => setTimeout(resolve, (seconds + Math.random()) * 1000));
  }
}
<?php
// Wait out a 429, honoring Retry-After. $send returns ['status' => int, 'headers' => array, ...].
function withRateLimit(callable $send): array
{
    while (true) {
        $res = $send();
        if ($res['status'] !== 429) {
            return $res;
        }
        $seconds = (float) ($res['headers']['retry-after'] ?? 60);
        usleep((int) (($seconds + mt_rand() / mt_getrandmax()) * 1_000_000));
    }
}

See Rate limits for budgeting a whole batch, and Retries and idempotency for combining this with other retries.

Listed by 46 operations

Your account has made more than 100 requests to this operation in the current 60-second window. Wait the seconds in Retry-After before sending again.

Your account has made more than 10 requests to this operation in the current hour. Wait the seconds in Retry-After before sending again. This operation has a budget of its own, lower than the 100 per 60 seconds every other operation gets.

500internal_error

Retry with backoff

An unexpected failure on the API's side. The operation may or may not have completed.

Default message
An unexpected error occurred.
Retry
Temporary. Retry with growing, jittered delays, and only requests that are safe to repeat.

Causes

A 500 means the API failed while handling your request, for a reason that has nothing to do with what you sent. The failure is logged with its request_id. The details are never included in the response.

{
  "error": {
    "code": "internal_error",
    "message": "An unexpected error occurred.",
    "documentation_url": "https://hub.main-team.org/api/errors#internal_error",
    "request_id": "6f5e4d3c-2b1a-4098-8765-43210fedcba9"
  }
}

A 500 can arrive on any operation, even while your token is still being checked, if something on our side is briefly unavailable. Most 500s carry the generic message above. A few name what happened:

MessageOperationWhat happened
Could not issue a sign-in token, please retry.create a sign-in linkThe link could not be issued. Nothing was issued, so retry at once.
File storage is not configured on this server.download a certificate or reportDownloads are unavailable on our side. Report it.

The operation may or may not have completed. A 500 can happen after a write has already been made. That matters for operations that aren't safe to repeat.

Downloads that stop halfway

If a certificate or report download fails after the file has started to arrive, the API can no longer send a status. It closes the connection instead. Your client sees a transfer that ends early or a network error, not a 500. Download the file again from the start.

Fixes

  1. Retry with backoff if the operation is safe to repeat. Every GET is, and so are application create, move and delete, the student updates, setting a password, the supervisor link, sign-in link requests and revoking a token. Wait about 1 second, then 2, then 4, with some randomness, and give up after five attempts. See Retries and idempotency.
  2. For a registration, retry and read the answer. POST /v1/student is not idempotent, but a repeat that answers 409 with an id in the message tells you the first attempt worked. See Registering a student.
  3. If many requests fail, pause. Stop your batch. GET /v1/health tells you when the API is reachable again, but it doesn't check what your operations need, so it can answer 200 while they still fail. Before you resume, send one cheap read your account is allowed, such as GET /v1/grade?limit=1, and wait until that succeeds.
  4. If it keeps happening, email info@main-team.org with the request_id, the UTC time and the operation. Don't include your token or secret.
Listed by 46 operations

Something failed on our side. Retry later, and quote request_id if it goes on.

The link could not be issued. Send the request again.

File storage is not configured or could not be reached.

503service_unavailable

Retry with backoff

createStudentImport is paused or the server is busy, a group challenge submit met a group someone else was changing, or the API is not ready. Nothing was written; wait the Retry-After seconds.

Default message
Not ready.
Retry
Temporary. Retry with growing, jittered delays, and only requests that are safe to repeat.

Causes

createStudentImport answers 503 for three reasons, and in every one of them nothing was queued and nothing was registered. Each carries Retry-After, in seconds:

WhyWhat to do
The server is already checking another batchWait the seconds in Retry-After and send it again. Checking a batch is the expensive part, and one server does one at a time.
Bulk registration is paused for a scheduled window, such as an exam morningSend it again after the window; Retry-After is how long it lasts. An import already queued is not lost — it waits and then runs.
Checking the batch took too longSend it again, or in smaller batches.

submitGroupChallengeStep and submitGroupChallengeWork answer 503 when someone else — a member in the panel or the app, or another request of yours — is changing the same group at that moment. Nothing was written. The answer carries Retry-After: 1 and error.details.reason: busy: wait a second and send the same request again. A retry that finds the work already done answers 200 with changed: false.

The other 503 is an internal readiness check the platform uses to decide whether an API instance should receive traffic. No other operation you call returns this code. It appears in the error catalog because that check shares the API's error format:

{
  "error": {
    "code": "service_unavailable",
    "message": "Not ready.",
    "documentation_url": "https://hub.main-team.org/api/errors#service_unavailable",
    "request_id": "a9b8c7d6-e5f4-4321-8fed-cba987654321"
  }
}

If you receive a 503, or a 502 or 504, while calling the API:

  • Without this JSON envelope, it came from the network in front of the API, for example during maintenance. Your request may not have reached the API.
  • With a different code, handle that code.

Fixes

  1. Treat it as temporary. Retry with jittered backoff (about 1, 2, 4 seconds and so on, at most five attempts), but only for operations that are safe to repeat. See Retries and idempotency.
  2. Monitor with the health check. GET /v1/health needs no token and doesn't count against your account's rate limit. It answers 200 while the API is up and reachable. It doesn't check what your operations need behind it, so it can answer 200 while they still fail:
    curl https://api.main-team.org/v1/health
    
    {
      "success": true,
      "message": "Request completed successfully.",
      "data": { "status": "ok" }
    }
    
  3. Pause batches while responses keep failing. Once the health check answers, send one cheap read your account is allowed, such as GET /v1/grade?limit=1, and resume when that succeeds.
  4. If it lasts more than a few minutes, email info@main-team.org with the UTC time and any request_ids you received. See Support.
Listed by 3 operations

This server is already checking another batch. Nothing was queued; wait the seconds in Retry-After and send it again.

Bulk registration is paused for a scheduled window, such as an exam morning. Nothing was queued; Retry-After is how long the window lasts. An import already queued is not lost — it waits and then runs.

Checking the batch took too long. Nothing was queued; send it again, or in smaller batches.

Someone else — a member in the panel or the app, or another request of yours — is changing the group at this moment. Nothing was written. Wait the second in Retry-After and send the same request again.

Search the API documentation

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