# Node.js client

> Install and use @main-team/api-client for Node.js and TypeScript. Covers setup, organizations, a complete flow, errors, pagination, downloads and rate limits.

`@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](https://hub.main-team.org/api/clients#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](mailto:info@main-team.org).

```bash
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. |

```ts
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:

```js
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.

**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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/clients#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](https://hub.main-team.org/api/clients/build-your-own).

## Check that your credentials work

`whoami()` calls [`GET /v1/api-account/validate-me`](https://hub.main-team.org/api/reference/get-current-api-account),
which answers with the account your token belongs to:

```ts
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.

```json
{
  "_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](https://hub.main-team.org/api/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:

```ts
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:

```ts
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.

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](https://hub.main-team.org/api/organizations) and [Permissions](https://hub.main-team.org/api/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](https://hub.main-team.org/api/tutorials/register-and-apply) tutorial walks through the same flow over
plain HTTP.

### Step 1: register the student

```ts
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:

```json
{
  "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:

```ts
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](https://hub.main-team.org/api/guides/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:

```ts
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:

```json
{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/…",
    "organization": "stem",
    "studentId": "66f2b7c1e4a9d20012ab34cd",
    "expiresIn": 120
  }
}
```

**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](https://hub.main-team.org/api/guides/sign-in-links).

In a web app, that looks like this:

```ts
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](https://hub.main-team.org/api/tutorials/send-student-to-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}`](https://hub.main-team.org/api/reference/list-available-exams),
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:

```ts
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](https://hub.main-team.org/api/guides/exams).

### Step 4: enter the student

```ts
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](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/errors).

```ts
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:

```ts
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](https://hub.main-team.org/api/authentication). |
| `403 forbidden` | No | Ask your operator for the permission. See [Permissions](https://hub.main-team.org/api/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.

```ts
const { data, pagination } = await client.students.list({ page: 2, limit: 50 });
console.log(`${data.length} of ${pagination.total}`);
```

`autoPaginate` walks every page:

```ts
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](https://hub.main-team.org/api/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`:

```ts
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](https://hub.main-team.org/api/guides/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](https://hub.main-team.org/api/rate-limits#what-per-operation-means)). 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:

```ts
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](https://hub.main-team.org/api/retries-and-idempotency) and [Rate limits](https://hub.main-team.org/api/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](https://hub.main-team.org/api/identifiers).

## Next steps

- [Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply): the full flow, end to end.
- [Collect results](https://hub.main-team.org/api/tutorials/collect-results): a nightly job for certificates and reports.
- [Token handling](https://hub.main-team.org/api/tutorials/token-handling): if you sign tokens yourself alongside the
  client.
- [PHP client](https://hub.main-team.org/api/clients/php) and [Build your own](https://hub.main-team.org/api/clients/build-your-own).
