Skip to content
API documentation
View as MarkdownOpen in Claude

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 @types package 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:

ValueWhat it isWhere it goes
apiKeyYour public account identifier, key_ followed by 24 characters.Anywhere. It's not a secret.
apiSecretYour 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:

ResultMeaningWhat to do
401 unauthorizedThe token was refused. Every token failure gives this same answer.Check the key, the secret and your server clock. See Authentication.
403 forbiddenThe 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
});
AnswerMeaning
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 conflictThe 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_foundThe 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 codeRetry?Typical fix
400 bad_requestNoFix the request. The message names the field.
401 unauthorizedNoCheck the key, the secret and your clock. See Authentication.
403 forbiddenNoAsk your operator for the permission. See Permissions.
404 not_foundNoCheck the id, and that the record is yours.
409 conflictNoRead the message: it names the rule.
429 too_many_requestsYes, after Retry-AfterSlow down. See below.
500 internal_errorYes, with backoffRetry 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, supervisor and partner as objects when they're set. An application read carries its exam, its payment and a short user object (_id, mainId, firstName, lastName). You don't need a second call per row. The exception is the application listing by exam, where exam stays 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 _id and mainId. The user on an application is the organization's copy of the student, so user._id is the organization's id for them. user.mainId is the id you registered them with. Match on mainId. See Identifiers.

Next steps

Search the API documentation

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