Help
Frequently asked questions
Short answers, grouped by topic. Each answer links to the page that explains the subject in full. If your question is really "why did I get this error?", go to Troubleshooting, which is organized by symptom.
The examples use $TOKEN for a token you have signed (see Authentication), <organizationId> for an organization's _id, and <studentId> for a student's _id. Every example calls production, https://api.main-team.org/v1.
Access
How do I get API credentials?
Access is by arrangement. You cannot sign up for it yourself. Write to info@main-team.org. An operator then creates an API account for your organization and gives you two values: an apiKey (public, starts with key_) and an apiSecret (private, starts with secret_). The same operator sets your account's roles, which decide which operations you may call. See Quickstart for your first call.
I lost my apiSecret. Can you send it again?
No. The secret is shown once, when the account is created, and nobody can show it to you again. The only way forward is a new account. Before you accept one, read the next two answers: a new account cannot see the students your old one registered.
Can I rotate my secret?
Not today. Secrets cannot be rotated in place. If your secret has leaked, ask support to deactivate the account. Within a minute, every token signed with that secret stops working. Then agree the next step with the operator. A replacement account is a different account and does not own your existing students, so plan how you will carry on before switching.
Security
If you only suspect that one token leaked, and not the secret, revoke that token with POST /v1/api-account/revoke-token. Your secret and your other tokens keep working.
Why can't my new account see the students my old account registered?
Every student belongs to the API account that registered them, and the API only ever shows an account its own students. To any other account, even a replacement account for the same company, those students answer exactly like records that do not exist. This rule is what keeps partners' data apart. See Organizations.
Which permissions do I need?
Every operation needs one permission, and its reference page names it. One case needs two: registering a student with a password also needs auth/signin on mto. For example, POST /v1/{organizationId}/application needs application/create on that organization. Operations without an organization in their path need the permission on mto. Permissions has ready-made role sets for common integrations. Tell the operator which profile you need.
My token works, but every call answers 403. Why?
403 forbidden means your token is valid but your roles do not grant this operation. Three cases are common:
- The two operations under
/v1/api-accountneed the actionapi/*(or*/*or*). A role for*/readdoes not grant them. - A flat operation, such as
GET /v1/student, needs a role whose target ismtoor*. A role onstemdoes not reach it. - Sign-in links and passwords need
auth/signin. Nostudent/*role grants it.
Only an operator can change roles, and a change takes effect within a minute. See Troubleshooting.
Can my account be limited to some organizations?
Yes. Every role names a target: one organization slug, or * for all of them. An account whose roles target only stem can act on stem and on nothing else. You still need roles on mto for the flat operations: registering students, reading reference data, and checking your own account.
Can several of our servers share one account?
Yes. Each server can sign its own tokens with the same key and secret. Keep in mind that the rate limit belongs to the account and not to the server. Ten servers share the same 100 requests per minute for each operation. See Rate limits.
Authentication
Why do I always get 401?
Every authentication failure answers with the same 401 unauthorized and the same message, so check your token against the rules:
- The header is exactly
Authorization: Bearer <token>, with a capitalBand one space. - The token is signed with HS256 using your
apiSecret. - The
kidheader is yourapiKey, exactly as issued. subequalskid.iatandexpare both present, as whole seconds, andexp - iatis at most 3600.- Your server's clock is within 30 seconds of the correct time.
- The token has not expired or been revoked, and your account is active.
Troubleshooting has a script that decodes your token and checks each rule.
Why doesn't the 401 say what is wrong?
On purpose. If the message named the failed check, anyone holding a leaked key or token could learn whether it still works. The reason is recorded against the request id. If the checklist does not find the problem, send the request_id from the error to support, and we can tell you which check failed.
How long can a token live?
At most 3600 seconds (one hour) from iat to exp. A token with a longer lifetime is refused, even if it has not expired yet. Tokens of 5 to 60 minutes work well.
Do I need a new token for every request?
No. Sign one token and reuse it for every request until shortly before it expires. For example, replace it 60 seconds before exp. Token handling shows a small cache in Node.js and PHP.
Is there a token endpoint or an OAuth flow?
No. You sign the token yourself, on your own server, with any JWT library that supports HS256. There are no keypairs, certificates or redirects involved. The API never sees your secret in a request.
How do I check that my credentials work?
Call GET /v1/api-account/validate-me. It returns your account, including the roles you hold.
curl -s https://api.main-team.org/v1/api-account/validate-me \
-H "Authorization: Bearer $TOKEN"
{
"_id": "66f1a2b3c4d5e6f708192a3b",
"apiKey": "key_Q2x5c3RhbGxpbmVfZXhhbXBs",
"companyName": "Example Schools Ltd",
"scopes": [],
"roles": [
{ "effect": "allow", "action": "*/read", "target": "*" },
{ "effect": "allow", "action": "api/*", "target": "mto" }
],
"isActive": true
}
Why is validate-me's response not wrapped in { success, data }?
It is the one JSON response without the envelope: it returns your account object directly. Every other JSON response uses { success, message, data }. Your client should handle this operation separately. See Requests and responses.
How do I end a token early?
Call POST /v1/api-account/revoke-token with the token you want to end in the Authorization header. Your account needs api/* on mto for this. From then on, the API refuses that token. expiresIn in the response tells you how many seconds the API keeps refusing it, which is until the token would have expired anyway.
curl -s -X POST https://api.main-team.org/v1/api-account/revoke-token \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Token revoked successfully",
"data": { "expiresIn": 1834 }
}
Our server clock is a little off. Does that matter?
Up to 30 seconds does not. The API accepts a token for 30 seconds after its exp, and it accepts an iat up to 30 seconds in the future. Beyond that, a clock that runs fast produces tokens whose iat is too far ahead, and a clock that runs slow produces tokens that have already expired. Both are refused with 401. Keep your servers synchronized with NTP.
Can we call the API from a browser or a mobile app?
No. Call it only from your servers. A token can only be signed with your apiSecret, and any secret shipped to a browser or an app belongs to everyone who installs it. The production API also sends no CORS headers, so browsers block cross-origin calls to it. To send a student into the student panel, let your server create a sign-in link and redirect the student's browser to it. See Security.
Students
What do I need to register a student?
A POST /v1/student with at least these fields:
| Field | Format |
|---|---|
firstName | Text, required |
lastName | Text, required |
email | A valid address, required |
birth | DD/MM/YYYY and a date that exists, required, for example 14/05/2011 |
sex | m, f or n, required |
country | A country _id from GET /v1/country, required |
grade | A grade _id from GET /v1/grade, or its name ("1" to "12"), required |
city | A city _id or its name, required |
school | A school _id or its name, required |
phone is optional. The full field list, including password and activatedPlatformsThisSeason, is in Students and on registerStudent.
Registration answers "A student with that email is already registered to this account". What now?
You registered that address before. The message ends with that student's _id. Fetch the student with GET /v1/student/{studentId}, or update them with PUT /v1/student/{studentId}. Registration is not idempotent: a repeated request never returns the existing student with 201.
Registration answers "That email address is already registered." with no id. Why?
An email address can belong to only one person on the whole platform. This address is already used by a student another partner registered, or by someone who signed up on their own. The API does not say who holds it, and it cannot attach an existing account to yours. An update that moves a student onto an address already in use gets the same answer. Register the student with a different address, or contact support if you believe the address is wrongly taken.
Can I send the grade, city or school as a name?
Yes. grade accepts the grade's _id or its name, which is "1" to "12". city and school accept an _id or a name. A city name is looked up within the country you send, and a school name within that country and city. Names are trimmed and matched without regard to case. country accepts only an _id. A value that matches nothing is refused with 400. The API never creates a city, school or grade for you. See Reference data.
Why is a country missing from GET /v1/country, or refused at registration?
Some countries cannot be selected. They are left out of the list, and sending one of their ids is refused with 400 country is not a known country., the same answer as an id that does not exist. Use only ids from the list.
How are usernames made, and can I choose one?
You cannot choose one. Registration issues the username, and the API never changes it. It is the country's two-letter code, one letter, and a sequence number, for example XXB1045 with the country's code in place of XX. Usernames are not secret: students and support staff use them to identify an account.
Should I set students' passwords?
Prefer sign-in links. A student who arrives through a link needs no password. If you do set one, the rules are:
- It must be at least 5 characters and at most 72 bytes.
- It must not contain the student's first name, surname, username, email address, or the part of the address before the
@. Case does not matter, and only parts of 4 or more characters count, so a two-letter surname rules nothing out. - Your account needs the
auth/signinpermission onmto. - Once the student has confirmed their own email address, you can no longer set it. The request answers
409, because the account now belongs to the student.
Generate a random password for each student. Never derive one from the student's record. See Passwords.
An update answers "property … should not exist". Why?
The API refuses any field it does not accept, rather than silently ignoring it. That way you never receive a success for a request that did less than you asked. The usual cause is sending a whole record back, including fields such as _id, username, fullName, emailConfirmed or createdAt. Send only the fields you want to change. password is refused on both update operations. It has its own operation.
What is the difference between /v1/student and /v1/{organizationId}/student?
Both work on the same student record.
/v1/studentoperations act on the core record. They are where you register students, change their profile and set passwords./v1/{organizationId}/studentoperations show the same record from one organization's point of view. The list includes only students with access to that organization, and an update there also gives the student access to it, unless the body sendsactivatedPlatformsThisSeason, whose values are added instead.
Linking a supervisor exists only in the organization form, because a supervisor belongs to one organization. See Students.
GET /v1/student/{studentId} answers 404 for a student I know exists. Why?
404 not_found with Student not found! means the student is not one your account can see. The student does not exist, or another account registered them, or, on the organization form, GET /v1/{organizationId}/student/{studentId}, the student has no access to that organization. The API answers these cases alike on purpose. Check that you are using the id registration returned, with a token for the account that registered the student.
How do I control which organizations a student can use?
With activatedPlatformsThisSeason. At registration it defaults to ["common"], which means every organization. To limit the student, send a list of slugs, for example ["stem", "neo"]. The slugs are stem, hilingua, neo, gmath and coding.
After registration the list only grows. Neither update removes an organization from it:
PUT /v1/{organizationId}/student/{studentId}without the field, even with an empty body{}, adds that organization, unless the list already holdscommon.- Either update with the field adds the values you send. A value the list already holds is not added twice, and
[]adds nothing.
So register a student with a narrow list only if they should stay off the other organizations.
How do I link a student to a supervisor, and why do I get "Not found!"?
Send the supervisor's username to PUT /v1/{organizationId}/student/{studentId}/supervisor:
curl -s -X PUT "https://api.main-team.org/v1/<organizationId>/student/<studentId>/supervisor" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"supervisorUsername": "XXT1003"}'
Every refusal answers the same 404 Not found!, so check each of these:
- The student is yours.
- The student has signed in to that organization at least once.
- The username exists. Spaces around it are removed and case does not matter, but a username made only of spaces answers
404too. - The account behind the username is a supervisor on that organization.
The link applies to that organization only. It is not shown by the student read operations, which return the core record. Keep the link call's response if you need it. See Supervisors.
Can I delete a student?
Not through the API in version 1. Contact support if a student record must be removed.
Exams and applications
Why doesn't an exam appear in GET /v1/{organizationId}/exam?
That operation lists only exams that are open for applications. An exam is open when all of these hold:
- Its session date is in the future.
- Its category is active.
- Applications to it are not switched off.
A closed exam is also refused by its single read (getExam), which answers 404, and by applications. The list is not a full catalog. See Exams.
Why is an exam missing from a student's available exams?
GET /v1/{organizationId}/exam/available/{studentId} offers only exams the student may actually take. An open exam is missing when at least one of these is true:
- It does not accept the student's grade.
- It is restricted to countries that do not include the student's country.
- It has no language set.
- The student already holds an exam in the same category on the same session.
The last rule is why a student entered for Science on one date is not offered Science in another language on that same date.
The country rule has one more edge. The student's country is matched by name in the organization's own country list. If the organization has no country of that name, the student is offered only exams that have no country restriction.
The available exams list answers "Student has no grade set". What do I do?
Every exam accepts a fixed set of grades, so a student without a grade cannot be offered anything. Set one, then list again:
curl -s -X PUT "https://api.main-team.org/v1/student/<studentId>" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"grade": "7"}'
How do I apply a student for an exam?
- List the student's available exams.
- Pick a leaf of the tree.
- Send its
matchedExam._idtogether with the student's id toPOST /v1/{organizationId}/application.
curl -s -X POST "https://api.main-team.org/v1/<organizationId>/application" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"studentId": "<studentId>", "examId": "<examId>"}'
Register and apply walks through the whole flow.
Applying answers "Student has never signed in to …". Why?
Applications live on the organization's own copy of the student, and that copy is created the first time the student opens a sign-in link for that organization. Creating the link is not enough: the student's browser has to open it. Until then, applications, the student's application list, certificates, reports and supervisor links on that organization all refuse. The refusal is 409 for most of them and 404 for the supervisor link.
Is it safe to retry POST /v1/{organizationId}/application?
Yes, when you retry with the same student and exam. If the application already exists, the API answers 200 with the message Application already exists. and the existing application. A new application answers 201. A different exam in the same category on the same session is refused with 409.
The exam is checked before the API looks for an existing application. Once the exam has closed, a retry answers 409 even though the application exists, so look for it in the student's application list instead. See Retries and idempotency.
Can I change an application's language or date?
Yes. Move it to the exam you want with PUT /v1/{organizationId}/application/{applicationId}, sending {"examId": "<examId>"}. The new exam has to be one the student's available exams list would offer. The student's current application does not count against the move, so switching language within the same session works. See Change an application.
Can I move a paid application?
Yes, if the new exam costs the same. The API checks a move in this order and answers 409 for the first rule it breaks:
- The student has already started or submitted the exam.
- The new exam is not one the student's available exams would offer: it is closed, not for the student's grade or country, or has no language. A student with no grade gets
400here instead. - The old exam's category does not allow a switch to the new one.
- The student already holds another exam in the new exam's category on the same session.
- The application is for the AI Challenge and part of its image quota has been used. This applies whether or not the application was paid for.
- The application has been paid for and the new exam has a different price. The API can neither charge the difference nor refund it.
Two other answers are not refusals. An examId that names no exam on that organization answers 404 Exam not found!. The exam the application already uses answers 200 with Application already uses that exam. and changes nothing.
A payment that has not been made yet follows the new exam's price automatically. It becomes paid with an amount of 0 if the new exam is free, and pending otherwise.
Can I delete an application?
Yes, unless it has been paid for. DELETE /v1/{organizationId}/application/{applicationId} refuses an application with a settled payment with 409, because cancelling a paid sitting needs a refund, and the API cannot make one. An application for a free exam is not "paid" in this sense and can be deleted. Deleting the same application a second time answers 404.
Does the API take payments or issue refunds?
No. When you create an application, the API also records its payment at the exam's price:
- For a free exam, the payment is recorded as paid with an amount of
0. - For a priced exam, the payment is recorded as pending. It is settled outside the API.
The API never charges or refunds anyone. In exam responses, price is left out for an exam nobody has priced. The API does not fill in 0 there, so write price ?? 0 in your own code if you need a number. When a student applies for such an exam, the payment is recorded as it is for a free exam: paid, with an amount of 0.
Sign-in links
How long is a sign-in link valid, and how often can it be used?
For 120 seconds, and once. Create it when the student clicks, then redirect their browser to it immediately. Never store a link, send it by email, or create links in advance. See Sign-in links and Send a student to the panel.
The student sees "Invalid or expired access token". Why?
The link had already been used, or it was more than 120 seconds old. A common hidden cause is a program that opened the link before the student did: a chat or email link preview, a security scanner, or browser prefetching. Any of these uses up the link. Create a fresh link at the moment of the click and answer with a 302 redirect, so the URL never appears anywhere a preview could fetch it.
The student has not confirmed their email address. Can they still sign in?
Yes. A sign-in link does not require a confirmed address. Students you register start out unconfirmed and can use a link straight away. Once they land, the panel asks them to confirm the address with a 6-digit code it emails to them, and asks on every page until they do. They can't put it off: the only way out is signing out. The prompt covers My Exams too, so they must confirm before they can start an exam. A student already inside an exam room is not interrupted. Have your students confirm well before an exam day, for example right after their first sign-in. A code is valid for 15 minutes, and they can ask for a new one after 60 seconds. See Email confirmation.
Creating a link answers 403 "Student is not activated for organization …". What do I do?
The student has no access to that organization. Their activatedPlatformsThisSeason holds neither common nor this organization's slug. Give them access with PUT /v1/{organizationId}/student/{studentId}, then create the link again. See How do I control which organizations a student can use?
Where does the student land?
On the organization's home page, unless you send redirect, a path inside the organization such as /dashboard. The path must start with a single / (// is refused) and contain no whitespace and no backslashes. The text {userId} in the path is replaced with the student's id on that organization. A full URL is refused with 400, which means a link can never send the student to another site.
Results
Why is a student's certificate or report list empty, or why does a download answer 404?
The API lists and serves only documents the organization has released. Results under embargo are not visible until they are published. A download answers the same 404 Not found! in each of these cases:
- The id is wrong.
- The document is not released yet.
- The document belongs to another account's student.
- The document has no file.
Listing a student's documents also requires the student to have signed in to that organization at least once. See Certificates and reports.
Can I download a document by its short id?
Yes. downloadCertificate and downloadReport accept either the document's _id or its shortId.
What file name should I save a download under?
Use the name in the Content-Disposition header. Prefer its filename*=UTF-8''… part, which carries the real name, including non-English letters. Fall back to filename="…" only when filename* is missing.
How often should we check for new results?
Once a day is enough for most integrations, because results are published in batches. Collect results shows a nightly job that stays within the rate limit and skips documents you already have.
Limits and environments
What are the rate limits?
Each API account may make 100 requests per 60 seconds to each operation. The same operation on different organizations shares one budget, and two different operations never do. Past the limit, the API answers 429 too_many_requests with a Retry-After header that gives the number of seconds to wait. Counted responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. See Rate limits.
How large can a request be?
A JSON body may be at most 100 kB. A larger body is refused with 413 payload_too_large before anything else is checked. The largest body the API expects is one student registration, which is far smaller.
Is there a sandbox?
Yes, at https://apisnd.main-team.org/v1. It runs the same release as production and has:
- separate accounts, issued by an operator from the sandbox's own panel (ask support)
- seeded reference data, with the same organization ids as production
- no emails sent and no real payments
Registration is open there, with the same permissions and rate limits as in production. The official client libraries accept only the production base URL, so call the sandbox over HTTPS directly. See Environments.
Can I use "Try it" in the reference?
Yes, against the sandbox only. Sign a token with your sandbox credentials on your own machine and paste the token, never your apiSecret. The console never sends requests to production. See Environments.
Is there a health or status endpoint?
GET /v1/health needs no token and doesn't count against your account's rate limit. It answers 200 while the API is up:
{
"success": true,
"message": "Request completed successfully.",
"data": { "status": "ok" }
}
It only says that the service is running. To check your integration end to end, including your token and roles, call GET /v1/api-account/validate-me.
How will we hear about changes?
Through the changelog, which lists every version and what changed in it. The version these pages describe is shown in the API reference. Every path stays under /v1 for the whole of version 1. Additive changes, such as new operations, new optional fields and new response fields, can arrive at any time, so your code should ignore fields it does not know.
Before we deprecate anything, we announce it in the changelog at least 6 months ahead. The affected responses also carry Deprecation and Sunset headers. Removals happen only in a new major version. See Versioning.
Are there client libraries?
Yes, for Node.js and PHP. They are private, and access comes with your credentials. Every operation is also plain HTTP with JSON, so you can use any HTTP client instead. See Client libraries.