Clients
Node.js client
@main-team/api-client is the official Node.js and TypeScript client for the Main Team API. This
page takes you from installation to a complete integration: you register a student, send them
into the panel, enter them for an exam, and collect their certificates. Each step explains which
API rule it relies on, so you know what to expect when a call fails.
The package README is the full method reference. This page covers the parts you need to build a working integration and the API behavior behind each of them.
Requirements
- Node.js 20 or newer. The client uses the built-in
fetch. - No runtime dependencies. ESM, CommonJS and TypeScript types ship in one package, so there's
no
@typespackage to install and no bundler required. - A server. The client holds your
apiSecret, so it runs on your backend only. See Server-side only.
Install
The package is private. Access comes with your API credentials: the access instructions you
receive with your apiKey and apiSecret tell you how to configure npm for it. If you have
credentials but no package access, write to info@main-team.org.
npm install @main-team/api-client
Set up the client
You need two values, both from your API credentials:
| Value | What it is | Where it goes |
|---|---|---|
apiKey | Your public account identifier, key_ followed by 24 characters. | Anywhere. It's not a secret. |
apiSecret | Your signing key, secret_ followed by random characters. Shown to you once, and it can't be recovered. | An environment variable or a secret store, on the server only. |
import { MtoClient } from '@main-team/api-client';
const client = new MtoClient({
apiKey: process.env.MTO_API_KEY!,
apiSecret: process.env.MTO_API_SECRET!,
});
The same in CommonJS:
const { MtoClient } = require('@main-team/api-client');
const client = new MtoClient({
apiKey: process.env.MTO_API_KEY,
apiSecret: process.env.MTO_API_SECRET,
});
Create one client per process and share it. The client signs short-lived tokens with your secret for you, so there's no token endpoint to call and no token for you to manage.
Security
The secret never leaves your process. Only the signed token travels, and a token expires within an hour at most. Never commit the secret to source control, bundle it into front-end code or write it to a log. If it leaks, ask your operator to deactivate the account. There is no secret rotation, so read Security before you need it.
There is no baseUrl option. The client talks to https://api.main-team.org/v1 and refuses
to be pointed anywhere else, because any other host would receive a bearer token it could replay
as your account. See Why the base URL is locked.
That includes the sandbox: to call https://apisnd.main-team.org/v1, send plain HTTPS requests, or
use the client from Build your own client.
Check that your credentials work
whoami() calls GET /v1/api-account/validate-me,
which answers with the account your token belongs to:
const account = await client.account.whoami();
console.log(account.companyName, account.roles);
On the wire, this is the one JSON route without the response envelope. It returns the account
object itself, without success, message or data around it.
{
"_id": "66f1a2b3c4d5e6f708192a3b",
"apiKey": "key_Q2hvb3NlQW5vdGhlcktleUhl",
"companyName": "Example Learning Ltd",
"scopes": [],
"roles": [
{ "effect": "allow", "action": "*/read", "target": "*" },
{ "effect": "allow", "action": "api/*", "target": "mto" },
{ "effect": "allow", "action": "student/*", "target": "mto" }
],
"isActive": true
}
If this fails, the error tells you where to look:
| Result | Meaning | What to do |
|---|---|---|
401 unauthorized | The token was refused. Every token failure gives this same answer. | Check the key, the secret and your server clock. See Authentication. |
403 forbidden | The token is fine, but your roles don't include api/* on mto, which this route needs. | Ask your operator for the permission. Other routes may still work. |
apiSecret is never returned by any route.
Choosing an organization
Most operations belong to one organization: mto (the core record), stem, hilingua, neo, gmath or
coding. The API's paths take the organization's _id, never its slug. The client resolves slugs
for you:
await client.loadOrganizations(); // one request, then cached
const stem = client.organization('stem');
const { data: exams } = await stem.exams.list();
loadOrganizations() also returns every organization, keyed by slug and ready to use:
const orgs = await client.loadOrganizations();
await orgs.coding.exams.list();
console.log(orgs.coding.organizationId); // the _id, if you need it
A slug your account can't reach throws an error that names the slugs it can, so a typo doesn't
come back as undefined three calls later. Passing a raw _id always works and skips the lookup.
Note
Operations without an organization in the path, such as students on the core record, countries,
grades and organizations, act on mto. Your roles need target mto or * for those. See
Organizations and Permissions.
A complete flow
This is the path most integrations follow: register a student, send them into an organization once, then enter them for an exam. The Register and apply tutorial walks through the same flow over plain HTTP.
Step 1: register the student
import { autoPaginate, MtoConflictError } from '@main-team/api-client';
// `country` takes an id only. Find it once and cache it: reference data rarely changes.
let albania;
for await (const country of autoPaginate((p) => client.countries.list(p))) {
if (country.iso2 === 'AL') { albania = country; break; }
}
const student = await client.students.register({
firstName: 'Jane',
lastName: 'Doe',
email: 'jane.doe@example.com',
birth: '14/05/2010', // DD/MM/YYYY
sex: 'f', // 'm', 'f' or 'n'
country: albania._id,
grade: '8', // a grade _id, or its name '1' to '12'
city: 'Berlin', // an id, or a name within the country
school: 'Berlin International School', // an id, or a name within country and city
});
console.log(student._id, student.username); // keep student._id: it's the id every route uses
grade, city and school accept either an id or a name. A name is looked up on the server and
refused with 400 bad_request if it matches nothing. The API never creates a city, a school or a
grade for you. Some countries can't be selected; they're left out of the country list and refused
at registration as if they didn't exist.
The API answers 201 and returns the new student:
{
"success": true,
"message": "User registered successfully.",
"data": {
"_id": "66f2b7c1e4a9d20012ab34cd",
"username": "XXK10427",
"firstName": "Jane",
"lastName": "Doe",
"fullName": "Jane Doe",
"email": "jane.doe@example.com",
"emailConfirmed": false,
"birth": "14/05/2010",
"sex": "f",
"country": "630e0182c53dc79a6836e67e",
"city": "63a4c2d1e0f9a80012345678",
"school": "64b1d3e2f1a0b90012345679",
"grade": "630e01826836e67ec53dc7ae",
"activatedPlatformsThisSeason": ["common"],
"createdAt": "2026-09-15T09:12:44.512Z",
"updatedAt": "2026-09-15T09:12:44.512Z"
}
}
Registration is not idempotent. If you retry after a timeout and the first attempt actually
succeeded, the retry answers 409 conflict. Handle that case explicitly:
try {
await client.students.register(profile);
} catch (error) {
if (error instanceof MtoConflictError) {
// "A student with that email is already registered to this account (66f2b7c1e4a9d20012ab34cd)."
const existingId = /\(([0-9a-f]{24})\)/.exec(error.message)?.[1];
if (!existingId) throw error; // another account holds this address: it can't be used
// fetch or update existingId instead of registering again
} else {
throw error;
}
}
The id is in the message because the message is where the API reports it. Store the id your side at registration so you rarely need this. See Students.
Step 2: send the student into the organization
An organization can only refer to a student after the student has signed in there once. That first sign-in creates the organization's copy of the student. Mint a sign-in link and redirect the student's browser to it:
const stem = client.organization('stem');
const link = await stem.auth.signinAsStudent({ studentId: student._id });
// link.url: redirect the browser here now. link.expiresIn: 120
The API answers:
{
"success": true,
"message": "Sign-in link generated successfully.",
"data": {
"url": "https://auth.main-team.org/…",
"organization": "stem",
"studentId": "66f2b7c1e4a9d20012ab34cd",
"expiresIn": 120
}
}
Danger
A sign-in link is a credential. It signs whoever opens it in as the student, it works once, and it expires 120 seconds after it was issued. Mint it when the student clicks, redirect straight away, and never store, log, email or paste it. Chat apps and email scanners open links to preview them, which uses the link up. See Sign-in links.
In a web app, that looks like this:
import express from 'express';
const app = express();
app.get('/go/olympiad', async (req, res, next) => {
try {
// Look the student up from YOUR session, never from a query parameter.
const studentId = req.session.mainTeamStudentId;
const link = await client.organization('stem').auth.signinAsStudent({ studentId });
res.redirect(302, link.url);
} catch (error) {
next(error);
}
});
The sign-in link is refused with 403 forbidden if the student has no access to that organization,
and with 404 not_found if the student isn't one of yours. The
Send a student to the panel tutorial covers both cases.
Step 3: choose an exam for the student
Pick from the exams this student can take, not from the organization's whole list. The picker,
GET /v1/{organizationId}/exam/available/{studentId},
applies the student's grade and country and leaves out what they already hold. Its tree runs
category, then session (date), then language, and every leaf carries a matchedExam. The package
README names the method that calls it. The organization's open exams are also available as a plain
list:
const { data: openExams } = await stem.exams.list(); // open exams only, for every student
An exam missing from that list is closed. It can't be read by id or applied to. See Exams.
Step 4: enter the student
const { data: application } = await stem.applications.create({
examId: leaf.matchedExam._id, // from the picker
studentId: student._id, // the core id from registration
});
| Answer | Meaning |
|---|---|
201 "Application created successfully." | A new application. |
200 "Application already exists." | The student already holds this exam. You get the existing application, so a retry is safe. |
409 conflict | The exam isn't one the picker would offer (closed, wrong grade or country, no language, or a clash with another exam in the same category on the same sitting), or the student has never signed in to this organization. The message says which. |
404 not_found | The exam or the student doesn't exist, or the student isn't yours. |
See Applications for moving and deleting applications, and how payments behave.
Errors
Every failure is thrown as an exception that carries the same code and HTTP status the API
returns. The codes are listed in the error reference.
import { MtoConflictError, MtoError } from '@main-team/api-client';
try {
await stem.applications.create({ examId, studentId });
} catch (error) {
if (error instanceof MtoConflictError) {
console.error(error.message, error.documentationUrl);
} else if (error instanceof MtoError) {
console.error(error.code, error.status);
} else {
throw error; // a network failure or a bug: not an API answer
}
}
A call that can't possibly succeed is rejected before the request is sent, as
MtoValidationError. It carries code: 'bad_request' and status: 400, the values the API would
have returned, so one handler covers both:
await client.students.register({ firstName: 'Jane', country: 'XX', grade: '8' });
// MtoValidationError: country must be a 24-character hex id, received 'XX'.
The client never adds a rule the API doesn't have. Anything the API accepts passes through
untouched, including properties the client doesn't model. The API then refuses properties it
doesn't declare with 400 bad_request, naming the property.
Branch on code and status. Treat message as text for people: it can change between
releases.
| Status and code | Retry? | Typical fix |
|---|---|---|
400 bad_request | No | Fix the request. The message names the field. |
401 unauthorized | No | Check the key, the secret and your clock. See Authentication. |
403 forbidden | No | Ask your operator for the permission. See Permissions. |
404 not_found | No | Check the id, and that the record is yours. |
409 conflict | No | Read the message: it names the rule. |
429 too_many_requests | Yes, after Retry-After | Slow down. See below. |
500 internal_error | Yes, with backoff | Retry later. If it persists, report the request_id. |
Pagination
List methods return { data, pagination }. pagination is
{ page, limit, total, totalPages }. page starts at 1, and limit defaults to 20 with a maximum
of 100. A larger value is lowered to 100 rather than refused.
const { data, pagination } = await client.students.list({ page: 2, limit: 50 });
console.log(`${data.length} of ${pagination.total}`);
autoPaginate walks every page:
import { autoPaginate } from '@main-team/api-client';
for await (const student of autoPaginate((p) => client.students.list(p))) {
console.log(student.username);
}
Each page is one request, and it counts against that route's rate limit. Walking 5,000 students at 100 per page is 50 requests, half of one minute's budget for that route. See Pagination.
Downloads
Certificate and report downloads return the PDF itself, not JSON. Only released documents can be
listed or downloaded. Pass either the document's _id or its shortId:
import { writeFile } from 'node:fs/promises';
import { basename } from 'node:path';
const file = await stem.certificates.download(certificateId); // _id or shortId
await writeFile(basename(file.fileName ?? 'certificate.pdf'), await file.bytes());
A document that doesn't exist, isn't released yet, isn't yours or has no file all answer the same
404 not_found. The file name comes from the server and can contain non-ASCII characters; take
its last path segment, as above, before you write it to disk. See
Certificates and reports.
Rate limits and retries
The limit is 100 requests per 60 seconds, per account, per operation, each operation counted
on its own (Rate limits). Every counted response
carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the
window resets). A 429 too_many_requests carries Retry-After in seconds.
Wait at least Retry-After seconds before calling that operation again. Requests sent during the
block are refused too, though they don't extend it. A block never lasts longer than one 60-second
window. So if the error you catch doesn't expose Retry-After, waiting 60 seconds always clears
it:
import { setTimeout as sleep } from 'node:timers/promises';
import { MtoError } from '@main-team/api-client';
async function withRateLimitRetry(call, attempts = 3) {
for (let attempt = 1; ; attempt++) {
try {
return await call();
} catch (error) {
const limited = error instanceof MtoError && error.status === 429;
if (!limited || attempt === attempts) throw error;
await sleep(60_000); // one full window always clears a block
}
}
}
const page = await withRateLimitRetry(() => client.students.list({ page: 1, limit: 100 }));
Only retry what is safe to repeat. Reads and POST /application are safe. POST /student isn't:
see step 1 above. Don't retry any other 4xx, and never retry a sign-in link. See
Retries and idempotency and Rate limits.
Responses and types
The package ships TypeScript types for the documented fields. Responses may gain fields over time without a new major version, so write your code to ignore fields it doesn't know. Two response details catch people out:
- Reads resolve their references. A student read carries
country,city,school,grade,supervisorandpartneras objects when they're set. An application read carries itsexam, itspaymentand a shortuserobject (_id,mainId,firstName,lastName). You don't need a second call per row. The exception is the application listing by exam, whereexamstays an id because every row shares the exam you asked for. Registration and updates answer with the reference fields as ids. - The student on an application carries
_idandmainId. Theuseron an application is the organization's copy of the student, souser._idis the organization's id for them.user.mainIdis the id you registered them with. Match onmainId. See Identifiers.
Next steps
- Register and apply: the full flow, end to end.
- Collect results: a nightly job for certificates and reports.
- Token handling: if you sign tokens yourself alongside the client.
- PHP client and Build your own.