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
| Rule | Value |
|---|---|
| Base URL | https://api.main-team.org/v1 (see Environments) |
| Transport | HTTPS, from your own servers |
| Request body | A JSON object, UTF-8, at most 100 kB — except createStudentImport, which takes 1.5 MB |
| Fields the operation does not declare | Refused with 400 bad_request |
| Success body | { success, message, data, pagination? } |
| Error body | { error: { code, message, documentation_url, request_id } } |
| Responses without that envelope | GET /v1/api-account/validate-me (the bare account) and the two file downloads (file bytes) |
| Request id | X-Request-Id header on every response |
| Ids | 24-character hexadecimal strings (see Identifiers) |
| Timestamps | ISO 8601 in UTC, for example 2026-09-15T08:30:12.345Z |
| Date of birth | DD/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/studentor/v1/countryact on the core record,mto. They take no organization id. - Organization paths such as
/v1/<organizationId>/examact on one organization.<organizationId>is that organization's_idfromGET /v1/organization, never its slug.
Organizations explains the difference, and Identifiers explains which id goes where.
Methods
| Method | Used for | Body |
|---|---|---|
GET | Reading a record, a list or a file | None |
POST | Creating something: registering a student, applying for an exam, creating a sign-in link, revoking your token | JSON. revoke-token takes none |
PUT | Changing a record | JSON |
DELETE | Deleting an application | None |
There is no PATCH. On the student routes, PUT changes only the fields you send and leaves the rest as they are.
Headers
| Header | When | Value |
|---|---|---|
Authorization | Every call except GET /v1/health | Bearer <token>. See Authentication |
Content-Type | Every POST or PUT that carries a body | application/json |
X-Request-Id | Optional, recommended | Your own id for this request. See Request ids |
Content-Encoding | Optional | gzip, 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 whatcurl -dsends 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 with400 bad_requestand a message such asproperty {"firstName":"Jane"} should not exist.- No
Content-Type, or a type such astext/plain: the body is ignored and the request arrives as if it had no fields. An operation with required fields refuses it with400 bad_request, naming a missing one. An update, where every field is optional, answers200without 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 with415 unsupported_media_type. Sendcharset=utf-8or no charset at all. - Compression is optional. Bodies are small, so most integrations never compress. If you do, use
gzip,deflateorbr. Any otherContent-Encodingis refused with415 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:
| Message | Meaning |
|---|---|
studentId must be a mongodb id | An id field that is not a 24-character hexadecimal string |
birth must be a real date in DD/MM/YYYY format | A date of birth in another format, or a date that does not exist |
email must be an email | An address that is not well formed |
property <name> should not exist | A 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 parameter | Malformed or unknown |
|---|---|
<organizationId> | 404 not_found, Organization not found!, checked only after your token has been accepted |
| Any other id, malformed | Usually 400 bad_request, for example Invalid value for '_id': expected ObjectId. |
| Any other id, well formed but matching nothing you can see | 404 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 downloads | Also 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:
| Field | Type | Meaning |
|---|---|---|
success | boolean | Always true on a successful response |
message | string | A short, human-readable summary, such as Countries fetched successfully. |
data | object or array | The result |
pagination | object | Only 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
| Status | When |
|---|---|
200 OK | Every 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 Created | A 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.
| Header | Value |
|---|---|
Content-Type | The type the file was stored with. Usually application/pdf, but it can be application/octet-stream, so do not rely on it |
Content-Length | The size in bytes, when it is known |
Content-Disposition | attachment; 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:
- 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). - 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-Lengthis 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"
}
}
| Field | Meaning |
|---|---|
code | A stable, machine-readable code, such as not_found or conflict. Branch on this together with the HTTP status |
message | A 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_url | A link to this code's entry on the errors page |
request_id | The same value as the response's X-Request-Id header. Quote it when you ask for help |
details | Only 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
| Status | code | Typical cause | Retry automatically? |
|---|---|---|---|
| 400 | bad_request | A body or id that breaks a rule, malformed JSON, an undeclared field | No. Fix the request |
| 401 | unauthorized | Any problem with the token. The message is always the same, whatever the problem was | No. Fix the token; see Authentication |
| 403 | forbidden | Your account lacks the permission (Insufficient role permissions), or the operation has a rule of its own and names it | No. Ask for the permission; see Permissions |
| 404 | not_found | Missing, not yours, unknown organization, or a path that matches no operation | No |
| 409 | conflict | The record's current state blocks the request; the message says which state, and a group challenge submit's error.details.reason too | No. Change the state first |
| 413 | payload_too_large | Body over 100 kB (1.5 MB on createStudentImport) | No |
| 415 | unsupported_media_type | Character set or compression the API does not read | No |
| 422 | unprocessable_entity | Rows of a student import that cannot be registered; error.details.rows names them. Nothing was queued | No. Fix the rows and send the batch again |
| 429 | too_many_requests | Rate limit reached | Yes, after Retry-After seconds; see Rate limits |
| 503 | service_unavailable | A 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 written | Yes, after Retry-After seconds |
| 500 | internal_error | Something failed on our side | Yes, 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 yourapiSecret. See Support.
Data formats
| Kind | Format | Example |
|---|---|---|
| Ids | 24 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 amounts | Numbers. 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 records | Resolved into objects on reads, plain ids on writes (see Identifiers) | "grade": { "_id": "…", "name": "10" } |
| Country names | Stored 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.