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.
Stable. One of the 12 codes below; together with the status it says what to do.
message
For people. Operations word it their own way, and the wording can change in any release. Never compare it in code.
documentation_url
The entry for the code on this page: https://hub.main-team.org/api/errors#<code>.
request_id
The 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.
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
}
}
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.
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.
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:
Message
Operations
Cause
email must be an email
register a student, update a student, update an organization student
email isn't a valid address.
email should not be empty
register a student, update a student, update an organization student
email is missing at registration, which requires one, or was sent as an empty string.
email must be a valid email address
update a student, update an organization student
email 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"
}
}
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.
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
Message
Cause
property <name> should not exist
The 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:
Message
Cause
email must be an email
email isn't a valid address.
email should not be empty
email is missing at registration, or is an empty string.
email must be a valid email address
An update sent email as null. An update can change the address but can't remove it.
firstName should not be empty
firstName is missing or empty at registration, or an update sent it empty or null.
lastName should not be empty
The same, for lastName. It is required at registration too.
firstName must be a string, lastName must be a string, grade must be a string
The 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 format
birth 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, n
sex has another value, or an update sent it as null.
phone must be a string
phone 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.
studentId must be a mongodb id, examId must be a mongodb id
The id isn't 24 hexadecimal characters.
email2 must be empty
email2 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 empty
The supervisor link needs a username.
activatedPlatformsThisSeason must be an array
activatedPlatformsThisSeason 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 string
A 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
Message
Cause
password must be longer than or equal to 5 characters or password must be at least 5 characters long
It is shorter than 5 characters. Either message can come first.
password must be a string
It isn't a string, for example a number.
password must be at most 72 bytes long, because bcrypt ignores anything past that
It 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 address
It 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:
Message
Cause
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
Message
Cause
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
Message
Cause
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.
Read the message. It names the field and the problem.
Send only documented fields. Build each body from the fields on the operation's reference page, not from a whole record.
Use the documented formats:DD/MM/YYYY for birth, m, f or n for sex, and 24-character hexadecimal ids.
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.
Complete the student (for example set a grade) before listing exams or applying.
Send Content-Type: application/json with a UTF-8 body.
Fix the problem and send the request again. Sending it again unchanged always gets the same 400.
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.
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").
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:
{
"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
Claim
Refused when
sub
It isn't your apiKey, the same value as kid.
iat, exp
Either is missing, or isn't a number of seconds.
iat, exp
They are in milliseconds (JavaScript's Date.now()). An iat in milliseconds lies far in the future.
exp
It isn't later than iat.
exp - iat
It is more than 3600 seconds (one hour).
iat
It is more than 30 seconds ahead of the API's clock. Your server's clock is fast.
exp
It passed more than 30 seconds ago. The token has expired.
nbf
You don't need this claim. If you send it, the token is refused until 30 seconds before the time it names.
Keep your server's clock synchronized with NTP. Clock drift is the usual reason a token that worked yesterday fails today.
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.
Check that the account is still active. If every token fails, even a correctly built one, your account may have been deactivated. Contact us.
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.
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.
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 called
Your account holds
Why it's refused
GET /v1/student
allow student/* on stem
Routes without {organizationId} act on mto. The role needs target mto or *.
POST /v1/{organizationId}/auth/signin, with stem's _id
allow student/* on stem
auth/signin is its own action. No student/* or api/* role includes it.
PUT /v1/student/{studentId}/password
allow auth/signin on stem
The password route has no {organizationId}, so it needs auth/signin on mto.
GET /v1/api-account/validate-me
allow */read on *
The account routes need api/*. Only api/*, */* or * grant it.
DELETE /v1/{organizationId}/application/{applicationId}, with stem's _id
allow application/* on *, disallow application/delete on *
A matching disallow always wins.
GET /v1/{organizationId}/exam, with neo's _id
allow exam/read on stem
The role covers stem only.
Anything
a role whose authorized is another account's id
That 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.
A sign-in link for a student without access to the organization
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.
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:
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.
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 student has no access to this organization: their activatedPlatformsThisSeason holds neither its slug nor common. Grant it with updateOrgStudent, then ask again.
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.
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
Message
Cause
Cannot GET /v1/students
No 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
Message
Cause
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
Message
Operations
Cause
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 reports
No 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.
A supervisor link
Message
Cause
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
Message
Operations
Cause
Application not found!
get, move or delete an application
No 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
Message
Operations
Cause
Exam not found!
get an exam, create or move an application
No 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 exam
The 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
Message
Operations
Cause
Not found!
download a certificate or report
Every 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.
No 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.
Check the path against the reference: the /v1 prefix, the spelling, and the method.
Use the organization's _id, not its slug. Get it from GET /v1/organization and store it.
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.
Use the organization the record lives in. Applications, exams, certificates and reports belong to one organization. Call the routes of that organization.
Check which account you are using. Records belong to the account that registered the student. A second account, even your own, cannot see them.
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.
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 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.
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.
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.
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.
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
Message
Operations
Cause
A student with that email is already registered to this account (652f1c9b8e4b2a0012a3c4d5).
register a student
You 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 student
Another 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
Message
Operations
Cause
You already have an import that has not finished. Read it, and send the next one when it has. (65f0c2a1d3e4f5a6b7c8d901)
register many students at once
One 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
Message
Operations
Cause
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 password
Once 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
Message
Operations
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:
Message
Cause
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:
Message
Cause
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.
{
"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.reason
Cause
challenge_closed
The organizers have closed the challenge.
window_closed
Now is outside the challenge's dates; details also carries windowStart and windowEnd.
payment_pending, group_not_confirmed
The teacher has not confirmed the group yet, so it has no steps.
step_locked
The step before this one is not submitted yet.
step_empty
Nothing has been uploaded for the step.
steps_incomplete
A 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
Message
Cause
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.
A 409 never goes away if you send the same request unchanged. Change the state or the request first.
You got
Do this
Email already registered to this account
Don'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 email
Don't set a password. Send the student a sign-in link.
Move the existing application instead of creating a second one, or choose another sitting.
Started exam, AI Challenge
The application can't be moved.
Category replacement refused
Moves from this category to that one aren't allowed. Choose an exam in a category the current one accepts.
Different price on a paid application
Choose an exam with the same price, or contact the organization about a refund. The API cannot charge or refund.
Paid application can't be deleted
The API cannot refund. Contact the organization.
A group challenge submit, step_empty or steps_incomplete
Wait 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 reason
The 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.
The student has never signed in to this organization, so it holds no record of them yet. Send them a sign-in link (createSigninLink), and try again once they have used it.
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 student already holds another exam in the same category on the same sitting, and nobody can sit both. Move that application (moveApplication) instead.
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
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.
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:
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.
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.
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 problem
What you get
A value in the request is invalid: wrong format, unknown field, reference data that doesn't exist, a student without a grade
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.
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
Header
Meaning
X-RateLimit-Limit
Requests allowed per window on this operation.
X-RateLimit-Remaining
Requests left in the current window.
X-RateLimit-Reset
Seconds until the current window ends.
Retry-After
On a 429 only: seconds to wait before sending to this operation again. The 429 itself carries no X-RateLimit-* headers.
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.
Pace yourself with X-RateLimit-Remaining. When it gets low, pause until X-RateLimit-Reset seconds have passed rather than running into the limit.
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).
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));
}
}
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.
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.
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:
Message
Operation
What happened
Could not issue a sign-in token, please retry.
create a sign-in link
The link could not be issued. Nothing was issued, so retry at once.
File storage is not configured on this server.
download a certificate or report
Downloads 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.
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.
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.
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.
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.
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.
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:
Why
What to do
The server is already checking another batch
Wait 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 morning
Send 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 long
Send 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:
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.
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:
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.
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.
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.