Skip to content
API documentation
View as MarkdownOpen in Claude

Concepts

Requests and responses

Every operation follows the same conventions: how you address it, what a body may contain, what comes back when it works and when it does not. Learn them once and every endpoint in the reference reads the same way. This page covers all of them, plus the two responses that break the pattern.

At a glance

RuleValue
Base URLhttps://api.main-team.org/v1 (see Environments)
TransportHTTPS, from your own servers
Request bodyA JSON object, UTF-8, at most 100 kB — except createStudentImport, which takes 1.5 MB
Fields the operation does not declareRefused with 400 bad_request
Success body{ success, message, data, pagination? }
Error body{ error: { code, message, documentation_url, request_id } }
Responses without that envelopeGET /v1/api-account/validate-me (the bare account) and the two file downloads (file bytes)
Request idX-Request-Id header on every response
Ids24-character hexadecimal strings (see Identifiers)
TimestampsISO 8601 in UTC, for example 2026-09-15T08:30:12.345Z
Date of birthDD/MM/YYYY, for example 14/05/2008

Anatomy of a request

The URL

A URL is the base URL plus the operation's path. Paths come in two families:

  • Flat paths such as /v1/student or /v1/country act on the core record, mto. They take no organization id.
  • Organization paths such as /v1/<organizationId>/exam act on one organization. <organizationId> is that organization's _id from GET /v1/organization, never its slug.

Organizations explains the difference, and Identifiers explains which id goes where.

Methods

MethodUsed forBody
GETReading a record, a list or a fileNone
POSTCreating something: registering a student, applying for an exam, creating a sign-in link, revoking your tokenJSON. revoke-token takes none
PUTChanging a recordJSON
DELETEDeleting an applicationNone

There is no PATCH. On the student routes, PUT changes only the fields you send and leaves the rest as they are.

Headers

HeaderWhenValue
AuthorizationEvery call except GET /v1/healthBearer <token>. See Authentication
Content-TypeEvery POST or PUT that carries a bodyapplication/json
X-Request-IdOptional, recommendedYour own id for this request. See Request ids
Content-EncodingOptionalgzip, deflate or br, only if you compress the body

You do not need an Accept header. The API answers with JSON, or with the file on the two download routes, whatever you send.

A complete request, with realistic output (the application object is shortened to its main fields):

curl -sS -i -X POST "https://api.main-team.org/v1/<organizationId>/application" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "X-Request-Id: 7d2f1c3a-9b8e-4f5a-a1b2-c3d4e5f60718" \
  -d '{"studentId":"<studentId>","examId":"<examId>"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
X-Request-Id: 7d2f1c3a-9b8e-4f5a-a1b2-c3d4e5f60718
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
X-RateLimit-Reset: 60
{
  "success": true,
  "message": "Application created successfully.",
  "data": {
    "_id": "66b2c3d4e5f60718293a4b5c",
    "exam": "64a1f0b2c9d8e7f600112233",
    "user": "66a0b1c2d3e4f5061728394a",
    "payment": "66b2c3d4e5f60718293a4b5d",
    "partners": [],
    "participated": false,
    "simulationStarted": false,
    "simulationSubmitted": false,
    "uuid": "a3f-09c-7e1",
    "createdAt": "2026-09-15T08:30:12.345Z",
    "updatedAt": "2026-09-15T08:30:12.345Z"
  }
}

Request bodies

Always a JSON object, sent as JSON

Send every body as a JSON object with Content-Type: application/json, encoded as UTF-8.

  • Wrong or missing Content-Type? The body is not read as JSON, and what happens next depends on what the header says:
    • application/x-www-form-urlencoded, which is what curl -d sends when you forget -H "Content-Type: application/json": your JSON text is read as a form with a single, oddly named field, and the request is refused with 400 bad_request and a message such as property {"firstName":"Jane"} should not exist.
    • No Content-Type, or a type such as text/plain: the body is ignored and the request arrives as if it had no fields. An operation with required fields refuses it with 400 bad_request, naming a missing one. An update, where every field is optional, answers 200 without making the changes you meant to send.

    If you get a 400 for a field you are sure you sent, a 400 naming a strange property, or an update seems to do nothing, check this header first.
  • Malformed JSON (a trailing comma, a missing quote) is refused with 400 bad_request. The message describes where parsing failed.
  • A character set that is not UTF-8, for example Content-Type: application/json; charset=latin1, is refused with 415 unsupported_media_type. Send charset=utf-8 or no charset at all.
  • Compression is optional. Bodies are small, so most integrations never compress. If you do, use gzip, deflate or br. Any other Content-Encoding is refused with 415 unsupported_media_type.

At most 100 kB

A body larger than 100 kB is refused with 413 payload_too_large. The refusal happens before your token is even read, so nothing was read or written, and the request does not count against your rate limit. No operation but one needs anywhere near that much: the largest body the rest of the API takes is one student registration.

The exception is createStudentImport, which takes a list of up to 1000 students and so accepts 1.5 MB. The limit belongs to that one operation and that one method: everything else, that path included, is still 100 kB.

Only the fields the operation declares

Every operation declares the fields its body may carry. A field it does not declare is refused, not ignored:

curl -sS -X PUT "https://api.main-team.org/v1/student/<studentId>" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"firstName":"Jane","password":"a new password"}'
{
  "error": {
    "code": "bad_request",
    "message": "property password should not exist",
    "documentation_url": "https://hub.main-team.org/api/errors#bad_request",
    "request_id": "0b5c6d0e-8f7a-4b1c-9d2e-3f4a5b6c7d8e"
  }
}

This is deliberate. If an unknown field were silently dropped, you would get a 200 for a request that did less than you asked, and you would not find out until something was missing later. In the example above, the password has its own operation (Passwords), and the refusal tells you so straight away.

In practice: build each body from the fields the reference lists for that operation. Never send a whole record copied out of your own system.

Values are checked too

Each declared field has rules: a type, a format, sometimes a list of allowed values. A value that breaks a rule is refused with 400 bad_request, and the message names the field. Some messages you are likely to meet:

MessageMeaning
studentId must be a mongodb idAn id field that is not a 24-character hexadecimal string
birth must be a real date in DD/MM/YYYY formatA date of birth in another format, or a date that does not exist
email must be an emailAn address that is not well formed
property <name> should not existA field the operation does not declare

The message names the first rule the body broke, not all of them. Fix that one and send again; if another field is also wrong, the next response names it.

A request refused by these checks changed nothing. The checks run before the operation does any work.

Query parameters

Only list operations take query parameters. Every list takes page and limit, which never cause an error: an out-of-range value is clamped to the nearest allowed one (see Pagination). Two lists also take a filter, which is refused with 400 when it cannot be read rather than ignored, since a filter dropped in silence would answer with every student instead of a few: email on listStudents and signedIn on listOrgStudents (see Students). No operation reads any other query parameter, so anything else you add is ignored.

Keep personal data and tokens out of URLs. URLs are the part of a request most likely to be recorded along the way. The email filter puts addresses in the URL, so use it only where that is acceptable to you.

Path parameters

Ids in the path are 24-character hexadecimal strings. What happens when one is wrong depends on which id it is:

Path parameterMalformed or unknown
<organizationId>404 not_found, Organization not found!, checked only after your token has been accepted
Any other id, malformedUsually 400 bad_request, for example Invalid value for '_id': expected ObjectId.
Any other id, well formed but matching nothing you can see404 not_found, on every single read and every write. The one exception is a list narrowed by an id, such as one exam's applications, which answers an empty page
<certificateId>, <reportId> on downloadsAlso accept a shortId. Anything that is not a 24-character id is looked up as one, so a typo answers 404, not 400

Identifiers has the full table, operation by operation.

Successful responses

The envelope

Every successful JSON response, apart from the one exception below, has this shape:

FieldTypeMeaning
successbooleanAlways true on a successful response
messagestringA short, human-readable summary, such as Countries fetched successfully.
dataobject or arrayThe result
paginationobjectOnly on list operations: { page, limit, total, totalPages }. See Pagination

A single record:

curl -sS "https://api.main-team.org/v1/grade/<gradeId>" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Grade fetched successfully.",
  "data": {
    "_id": "5f1a2b3c4d5e6f7a8b9c0d1e",
    "name": "10",
    "createdAt": "2020-08-01T09:00:00.000Z",
    "updatedAt": "2020-08-01T09:00:00.000Z"
  }
}

A list (each country shortened to a few of its fields):

curl -sS "https://api.main-team.org/v1/country?limit=2" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Countries fetched successfully.",
  "data": [
    {
      "_id": "630e0182c53dc79a6836e67e",
      "name": "GERMANY",
      "iso2": "DE",
      "iso3": "DEU",
      "dialCode": "+49"
    },
    {
      "_id": "630e0182c53dc79a6836e67f",
      "name": "AUSTRIA",
      "iso2": "AT",
      "iso3": "AUT",
      "dialCode": "+43"
    }
  ],
  "pagination": { "page": 1, "limit": 2, "total": 196, "totalPages": 98 }
}

Status codes on success

StatusWhen
200 OKEvery read, every update, deleting an application, revoking a token, creating a sign-in link, and applying for an exam the student already holds (message: Application already exists.)
201 CreatedA student was registered (POST /v1/student) or an application was created (POST /v1/<organizationId>/application)

The difference matters on POST /v1/<organizationId>/application. Sending the same student and exam twice does not create a second application: the second call answers 200 with the application that already exists. The status tells you which happened. See Retries and idempotency.

Read the data, not the message

message is for people and logs. Wording can change without notice, so never branch on it. Decide what happened from the status code and from data.

Expect fields you did not ask for

Objects can carry fields that no guide mentions, such as __v, and new fields can be added within version 1 (see Versioning). Parse leniently: read the fields you use and ignore the rest. A client that fails on an unexpected field will break on a change that is not a breaking change.

Two responses without the envelope

GET /v1/api-account/validate-me

This operation answers with your account object itself, not wrapped in data:

curl -sS "https://api.main-team.org/v1/api-account/validate-me" \
  -H "Authorization: Bearer $TOKEN"
{
  "_id": "665f0c1d2e3a4b5c6d7e8f90",
  "apiKey": "key_Q2x9mT4vLp8sR1nW6yZ3aB7c",
  "companyName": "Northbridge Learning",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "*/read", "target": "*" },
    { "effect": "allow", "action": "api/*", "target": "mto" }
  ],
  "isActive": true
}

The fields are _id, apiKey, companyName, scopes, roles and isActive. Each role is { effect, action, target }, plus authorized when the operator set one (see Permissions). Your apiSecret is never returned, on this route or any other. If this call fails, the error still arrives in the normal error envelope. Your API account shows how to use this call as a health check for your integration.

POST /v1/api-account/revoke-token is not an exception. It answers with the usual envelope: message is Token revoked successfully and data is { "expiresIn": <seconds> }.

Certificate and report downloads

GET /v1/<organizationId>/certificate/download/<certificateId> and GET /v1/<organizationId>/report/download/<reportId> answer with the file itself.

HeaderValue
Content-TypeThe type the file was stored with. Usually application/pdf, but it can be application/octet-stream, so do not rely on it
Content-LengthThe size in bytes, when it is known
Content-Dispositionattachment; filename="<ascii name>"; filename*=UTF-8''<percent-encoded name>

Take the file name from filename*. It carries the real name, including letters outside ASCII. filename is an ASCII version for clients that cannot read filename*. For a file called Öğrenci Şükrü.pdf the header is:

Content-Disposition: attachment; filename="Ogrenci Sukru.pdf"; filename*=UTF-8''%C3%96%C4%9Frenci%20%C5%9E%C3%BCkr%C3%BC.pdf

Two rules keep downloads reliable:

  1. Check the status before you write a file. Every refusal is sent before any file bytes, as the normal JSON error envelope. A missing, unreleased or foreign document all answer the same 404 not_found, Not found! (see Certificates and reports).
  2. A transfer that stops early is a failed download. If something fails after the first bytes have been sent, the connection is closed; you do not get a short file with a success status. When Content-Length is present, compare it with the bytes you received and discard the file if they differ.

With curl, write to a file and print the status alongside it:

curl -sS -o certificate.pdf -w '%{http_code} %{content_type} %{size_download}\n' \
  -H "Authorization: Bearer $TOKEN" \
  "https://api.main-team.org/v1/<organizationId>/certificate/download/<certificateId>"
# 200 application/pdf 184213

If the first number is not 200, certificate.pdf contains the JSON error, not a PDF.

In Node.js (18 or later):

import { createWriteStream } from 'node:fs';
import { rename, stat, unlink } from 'node:fs/promises';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';

function fileNameFrom(disposition, fallback) {
  const star = /filename\*=UTF-8''([^;]+)/i.exec(disposition ?? '');
  if (star) return decodeURIComponent(star[1]);
  const plain = /filename="([^"]*)"/i.exec(disposition ?? '');
  return plain ? plain[1] : fallback;
}

export async function downloadCertificate(token, organizationId, certificateId, dir) {
  const res = await fetch(
    `https://api.main-team.org/v1/${organizationId}/certificate/download/${certificateId}`,
    { headers: { Authorization: `Bearer ${token}` } },
  );
  if (!res.ok) {
    const body = await res.json().catch(() => null);
    throw new Error(`${res.status} ${body?.error?.code}: ${body?.error?.message} (${body?.error?.request_id})`);
  }

  // Keep only the last path segment, so a stored name can never leave `dir`.
  const name = fileNameFrom(res.headers.get('content-disposition'), `${certificateId}.pdf`)
    .split(/[\\/]/)
    .pop();
  const partial = `${dir}/${name}.part`;
  try {
    await pipeline(Readable.fromWeb(res.body), createWriteStream(partial));
  } catch (error) {
    await unlink(partial).catch(() => {}); // the connection closed mid-file
    throw error;
  }

  const expected = Number(res.headers.get('content-length') ?? NaN);
  const { size } = await stat(partial);
  if (Number.isFinite(expected) && size !== expected) {
    await unlink(partial);
    throw new Error(`Incomplete download: ${size} of ${expected} bytes`);
  }
  await rename(partial, `${dir}/${name}`);
  return `${dir}/${name}`;
}

In PHP (8.1 or later, with ext-curl):

<?php
function downloadCertificate(string $token, string $organizationId, string $certificateId, string $dir): string
{
    $headers = [];
    $partial = tempnam($dir, 'dl_');
    $fh = fopen($partial, 'wb');

    $ch = curl_init("https://api.main-team.org/v1/{$organizationId}/certificate/download/{$certificateId}");
    curl_setopt_array($ch, [
        CURLOPT_HTTPHEADER => ["Authorization: Bearer {$token}"],
        CURLOPT_FILE => $fh,
        CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$headers) {
            $parts = explode(':', $line, 2);
            if (count($parts) === 2) {
                $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
            }
            return strlen($line);
        },
    ]);
    $ok = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $received = curl_getinfo($ch, CURLINFO_SIZE_DOWNLOAD);
    curl_close($ch);
    fclose($fh);

    if ($ok === false || $status !== 200) {
        $error = json_decode((string) file_get_contents($partial), true)['error'] ?? [];
        unlink($partial);
        throw new RuntimeException("{$status} " . ($error['code'] ?? 'error') . ': ' . ($error['message'] ?? '') . ' (' . ($error['request_id'] ?? '-') . ')');
    }
    if (isset($headers['content-length']) && (int) $headers['content-length'] !== (int) $received) {
        unlink($partial);
        throw new RuntimeException("Incomplete download: {$received} of {$headers['content-length']} bytes");
    }

    $name = "{$certificateId}.pdf";
    $disposition = $headers['content-disposition'] ?? '';
    if (preg_match("/filename\\*=UTF-8''([^;]+)/i", $disposition, $m)) {
        $name = rawurldecode($m[1]);
    } elseif (preg_match('/filename="([^"]*)"/i', $disposition, $m)) {
        $name = $m[1];
    }
    $target = $dir . '/' . basename($name); // basename: never let a stored name leave $dir
    rename($partial, $target);
    return $target;
}

Errors

The error envelope

Every error, from every operation, has this shape:

{
  "error": {
    "code": "not_found",
    "message": "Application not found!",
    "documentation_url": "https://hub.main-team.org/api/errors#not_found",
    "request_id": "7d2f1c3a-9b8e-4f5a-a1b2-c3d4e5f60718"
  }
}
FieldMeaning
codeA stable, machine-readable code, such as not_found or conflict. Branch on this together with the HTTP status
messageA human-readable explanation. On 409 and some 400 and 403 responses it names exactly what blocked the request, so log it and show it to whoever has to fix the problem. Never parse it
documentation_urlA link to this code's entry on the errors page
request_idThe same value as the response's X-Request-Id header. Quote it when you ask for help
detailsOnly where an operation says so, an object it cannot put in one sentence: the rows of a student import that cannot be registered (422), and on the two group challenge submits the reason of a 409 or a 503 (details.reason). Absent everywhere else; ignore it on an operation that does not document it

Status codes

StatuscodeTypical causeRetry automatically?
400bad_requestA body or id that breaks a rule, malformed JSON, an undeclared fieldNo. Fix the request
401unauthorizedAny problem with the token. The message is always the same, whatever the problem wasNo. Fix the token; see Authentication
403forbiddenYour account lacks the permission (Insufficient role permissions), or the operation has a rule of its own and names itNo. Ask for the permission; see Permissions
404not_foundMissing, not yours, unknown organization, or a path that matches no operationNo
409conflictThe record's current state blocks the request; the message says which state, and a group challenge submit's error.details.reason tooNo. Change the state first
413payload_too_largeBody over 100 kB (1.5 MB on createStudentImport)No
415unsupported_media_typeCharacter set or compression the API does not readNo
422unprocessable_entityRows of a student import that cannot be registered; error.details.rows names them. Nothing was queuedNo. Fix the rows and send the batch again
429too_many_requestsRate limit reachedYes, after Retry-After seconds; see Rate limits
503service_unavailableA student import is paused, or the server is busy checking another; or a group challenge submit met a group someone else was changing (details.reason: busy). Nothing was writtenYes, after Retry-After seconds
500internal_errorSomething failed on our sideYes, with backoff; see Retries and idempotency

Every code, with its causes and fixes, is on the errors page.

A foreign record looks exactly like a missing one

You can only ever reach your own students and the records that hang off them. A student, application, certificate or report that belongs to another account answers with the same status and code as one that does not exist: 404 not_found. This is on purpose: an answer that differed would tell you which ids exist in another account's hands. Treat every one of them as "not found" and branch on the status and code, never on the message. Organizations explains who "your" students are.

A path that matches no operation

A typo in the path, or a method the path does not support, answers 404 not_found with a message such as Cannot GET /v1/students. So does the bare host, https://api.main-team.org/. If you get this for a path you believe exists, compare it character by character with the reference.

Request ids

Every response, successful or not, carries an X-Request-Id header. Every error body carries the same value as error.request_id. It is the one value that lets support find your request in our logs.

You can choose the id yourself by sending an X-Request-Id header. The API reuses it if it is 1 to 256 characters long and uses only letters, digits and . _ : ; = + / @ -. Otherwise, or if you send none, the API assigns an id of its own and returns that instead. Its format is not fixed, so store it as an opaque string. Send the header once per request: a repeated header is not reused.

What we recommend:

  • Generate a fresh UUID for every request, retries included, so each attempt can be told apart.
  • Log it next to your own record of the call: the operation, your internal job or user id, and the time in UTC.
  • Never put personal data, a token or a secret into it. It is written to logs.
  • When something goes wrong, send support the request_id, the time in UTC and the operation. Never send a token or your apiSecret. See Support.

Data formats

KindFormatExample
Ids24 lowercase hexadecimal characters, as a string"652f1c9b8e4b2a0012a3c4d5"
Timestamps (createdAt, updatedAt, exam session dates)ISO 8601, UTC, with milliseconds"2026-09-15T08:30:12.345Z"
Date of birth (birth)A DD/MM/YYYY string, in requests and in responses"14/05/2008"
Prices and amountsNumbers. An exam that has no price has no price field at all; do not read the absence as 0 (see Exams)25
References to other recordsResolved into objects on reads, plain ids on writes (see Identifiers)"grade": { "_id": "…", "name": "10" }
Country namesStored in capitals"GERMANY"

Putting it together: a small request helper

Most integrations wrap the conventions on this page in one function. These helpers set the headers, send a request id, return the parsed envelope and turn every error into an exception that carries the status, code and request id. Token signing is covered in Authentication.

Node.js (18 or later):

import { randomUUID } from 'node:crypto';

const BASE = process.env.MTO_API_BASE ?? 'https://api.main-team.org/v1';

export class ApiError extends Error {
  constructor(status, error, requestId) {
    super(error?.message ?? `HTTP ${status}`);
    this.status = status;
    this.code = error?.code ?? 'unknown';
    this.requestId = error?.request_id ?? requestId;
    this.documentationUrl = error?.documentation_url;
  }
}

export async function api(token, method, path, body) {
  const requestId = randomUUID();
  const res = await fetch(`${BASE}${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${token}`,
      'X-Request-Id': requestId,
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  const text = await res.text();
  let json = null;
  try {
    json = text ? JSON.parse(text) : null;
  } catch {
    // Not JSON: something between you and the API answered. Keep the status.
  }

  if (!res.ok) throw new ApiError(res.status, json?.error, requestId);
  return { status: res.status, body: json, requestId: res.headers.get('x-request-id') };
}

// Usage
const { status, body } = await api(token, 'POST', `/${organizationId}/application`, {
  studentId,
  examId,
});
console.log(status === 201 ? 'created' : 'already existed', body.data._id);

PHP (8.1 or later):

<?php
final class ApiError extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly string $errorCode,
        string $message,
        public readonly string $requestId,
    ) {
        parent::__construct($message);
    }
}

function api(string $token, string $method, string $path, ?array $body = null): array
{
    $base = getenv('MTO_API_BASE') ?: 'https://api.main-team.org/v1';
    $requestId = bin2hex(random_bytes(16));
    $headers = ["Authorization: Bearer {$token}", "X-Request-Id: {$requestId}"];

    $ch = curl_init($base . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT => 30,
    ]);
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
    }
    curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);

    $raw = curl_exec($ch);
    if ($raw === false) {
        throw new RuntimeException('Network error: ' . curl_error($ch));
    }
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    $json = json_decode($raw, true);
    if ($status >= 400) {
        $error = $json['error'] ?? [];
        throw new ApiError($status, $error['code'] ?? 'unknown', $error['message'] ?? "HTTP {$status}", $error['request_id'] ?? $requestId);
    }
    return ['status' => $status, 'body' => $json];
}

// Usage
$result = api($token, 'POST', "/{$organizationId}/application", ['studentId' => $studentId, 'examId' => $examId]);
echo ($result['status'] === 201 ? 'created ' : 'already existed ') . $result['body']['data']['_id'];

To page through lists, see Pagination. To stay inside the rate limit, see Rate limits.

Search the API documentation

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