Clients
Build your own client
The official clients cover Node.js and PHP. The API has no special access for them, so a client in any language works as well as theirs, provided it follows the rules on this page. You'll find:
- The rules every client has to follow, with the reason for each.
- A minimal client in Node.js (
fetchandjsonwebtoken, about 90 lines) and in PHP (curlandfirebase/php-jwt, about 130 lines). - A conformance checklist to test your client against.
Along with this page you need the API reference for routes and fields, and the error reference for codes.
The rules
Rule 1: you sign your own token
There is no token endpoint. You create a JSON Web Token yourself and sign it with your apiSecret:
| Part | Field | Value |
|---|---|---|
| Header | alg | HS256. No other algorithm is accepted. |
| Header | typ | JWT. Usual, but not checked. |
| Header | kid | Your apiKey, exactly as issued: key_ followed by 24 characters from A–Z a–z 0–9 _ -. |
| Payload | sub | Your apiKey again. It must equal kid. |
| Payload | iat | Issued-at time, in whole seconds since the Unix epoch. Required. |
| Payload | exp | Expiry time, in seconds since the epoch. Required, after iat, and at most 3600 seconds after it. |
| Payload | nbf | Optional. If you set it, it's enforced with the same 30-second tolerance. Most clients leave it out. |
Send the token as Authorization: Bearer <token>. The server allows 30 seconds of clock
difference: a token is still accepted up to 30 seconds after its exp, and its iat may be up to
30 seconds ahead of the server's clock. An iat further ahead is refused, so keep your server
clock synchronized.
Reuse a token until about a minute before its exp, then sign a new one. Signing is cheap,
but a token per request adds nothing. Each of your servers can sign its own token: the rate limit
belongs to your account, not to a token.
Every authentication failure is the same 401 unauthorized, with the same message:
Authentication is required or the provided credentials are invalid. A missing header, a wrong
key, a deactivated account, a bad signature, a missing iat, an expired token, an over-long token
and a revoked token all look alike. The server doesn't say which check failed, so a leaked key or
token tells whoever found it nothing. Check your token against the table above, and quote the
request_id when you ask for help. See Authentication.
To end a token early, call POST /v1/api-account/revoke-token,
authenticated with that token. It needs the same api/* permission on mto as validate-me. It
answers 200 with the message Token revoked successfully and data.expiresIn: how many seconds
the revocation is held, which is the token's remaining lifetime plus the 30-second tolerance. The
token is refused from then on, and your other tokens keep working.
Rule 2: every JSON response has an envelope
A successful response looks like this (the student is shortened to two of its fields):
{
"success": true,
"message": "Students fetched successfully.",
"data": [ { "_id": "66f2b7c1e4a9d20012ab34cd", "username": "XXK10427" } ],
"pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}
pagination appears on lists only. An error looks like this:
{
"error": {
"code": "conflict",
"message": "A student with that email is already registered to this account (66f2b7c1e4a9d20012ab34cd).",
"documentation_url": "https://hub.main-team.org/api/errors#conflict",
"request_id": "3f1c9a52-7d3e-4a8b-9e51-0c2d4b6a8f10"
}
}
One route has no envelope. GET /v1/api-account/validate-me
returns the account object itself.
Branch on the HTTP status and error.code. message is written for people and can change. See
Requests and responses and Errors.
A single read answers 404 not_found when nothing matches. A student that doesn't exist and one
that isn't yours answer alike, and so does every other record your account can't see. A malformed
id is 400 bad_request.
Rule 3: every response carries a request id
Every response, errors included, has an X-Request-Id header, and an error body repeats it as
error.request_id. You can send your own X-Request-Id and the API will use it, if it is 1 to 256
characters from A–Z a–z 0–9 . _ : ; = + / @ - (a UUID works). Anything else is ignored, and the
API assigns an id itself. An assigned id isn't always a UUID, so store it as an opaque string. Log
it next to your own request log, and quote it in a support request.
Rule 4: paths, ids and bodies
- Every route is under
https://api.main-team.org/v1in production andhttps://apisnd.main-team.org/v1in the sandbox. HTTPS only. - Organization routes take the organization's
_id, a 24-character hexadecimal id fromGET /v1/organization, never its slug. Look the ids up once and cache them. An id that names no organization answers404 not_foundwithOrganization not found!. - Routes without an organization in the path act on mto, the core record.
- Send bodies as UTF-8 JSON with
Content-Type: application/json, at most 100 kB. A larger body is refused with413 payload_too_largebefore the token is even read. A character set or content encoding the API can't read is415 unsupported_media_type. Uncompressed bodies are accepted, and so are bodies sent withContent-Encoding: gzip,deflateorbr. - Send only the fields the reference documents. Any other property is
400 bad_request, and the message names it.
Rule 5: lists are paginated
page starts at 1. limit defaults to 20 and is capped at 100. Values outside that range are
clamped, not refused, and a value that isn't a number falls back to the default. Walk pages until
page reaches pagination.totalPages. totalPages is 0 when the list is empty. Records can be
added between your requests, so de-duplicate by _id when you walk a long list. See
Pagination.
Rule 6: downloads are not JSON
Certificate and report downloads return the file itself, with no envelope:
Content-Typeis the stored type, usuallyapplication/pdf.Content-Dispositionisattachment; filename="<ascii>"; filename*=UTF-8''<percent-encoded>. Preferfilename*, which carries the real name, including non-ASCII characters. Fall back tofilenameonly whenfilename*is missing. Either way, strip any path before you write the file.Content-Lengthis present when the size is known.- An error arrives before any bytes, as the usual JSON error with a
4xxor5xxstatus. Check the status before you write anything. - If the connection closes partway through the body, the download failed. Discard the partial file and try again.
Rule 7: 403 is a permission
403 forbidden means your token is valid but your account lacks the permission for that route on
that organization. Re-signing or retrying won't change that; ask your operator for the role. A few
routes use 403 for a rule of their own and say so in the message, such as a sign-in link for a
student with no access to that organization. See Permissions.
Rule 8: 429 means wait
Each account may make 100 requests per 60 seconds to each operation, wherever the requests come from, and each operation is counted on its own. Counted responses carry:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed per window for this operation. |
X-RateLimit-Remaining | Requests left in the current window. |
X-RateLimit-Reset | Seconds until the window resets. |
A 429 too_many_requests carries Retry-After, in seconds. Wait that long. Requests sent sooner
are refused too, though they don't extend the block, and a block never lasts longer than one
window. Requests refused before they're counted don't use up your budget. These are a 401, a
403 for a missing permission, an unknown organization, and a body refused as too large or
unreadable. A request refused later, such as a 400 for a bad field, is counted. See
Rate limits.
Rule 9: retry only what is safe
| Request | Safe to retry? |
|---|---|
Any GET | Yes. |
POST /v1/{organizationId}/application | Yes, for the same exam: a repeat answers 200 "Application already exists." with the existing application. |
PUT on a student | Yes: repeating it writes the same values. |
PUT /v1/{organizationId}/application/{applicationId} | Yes: once the move has happened, a repeat answers 200 "Application already uses that exam." |
DELETE /v1/{organizationId}/application/{applicationId} | Yes, but the second call answers 404 not_found because the first one worked. |
POST /v1/student | No. A repeat answers 409 conflict naming the existing student. Fetch that student instead. |
POST /v1/{organizationId}/auth/signin | Mint a new link instead. Never retry, fetch or prefetch a link URL: it works once. |
Retry network errors and 5xx answers, on requests that are safe to repeat, with exponential
backoff and jitter. Don't retry 4xx
answers, with two exceptions: 429 after Retry-After, and a 401 once after signing a fresh
token, in case yours expired mid-flight. See
Retries and idempotency.
Rule 10: server-side only
In production the API sends no CORS headers, so browsers on other origins can't call it. Keep
your client, and your apiSecret, on your servers.
A minimal client in Node.js
About 90 lines. It needs Node.js 20 or newer for the built-in fetch, and one dependency:
npm install jsonwebtoken
// main-team-client.mjs: a minimal Main Team API client (Node.js 20+).
import jwt from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
const BASE_URL = 'https://api.main-team.org/v1'; // sandbox: 'https://apisnd.main-team.org/v1'
const TOKEN_LIFETIME = 900; // seconds; the API accepts at most 3600
const RENEW_BEFORE = 60; // sign a new token this long before exp
export class ApiError extends Error {
constructor(status, body, headers) {
super(body?.error?.message ?? `HTTP ${status}`);
this.status = status;
this.code = body?.error?.code ?? 'unknown';
this.documentationUrl = body?.error?.documentation_url;
this.requestId = body?.error?.request_id ?? headers.get('x-request-id');
this.retryAfter = Number(headers.get('retry-after')) || undefined;
}
}
export class MainTeamClient {
#apiKey; #apiSecret; #token = null; #tokenExp = 0;
constructor({ apiKey, apiSecret }) {
if (!/^key_[A-Za-z0-9_-]{24}$/.test(apiKey ?? '')) throw new Error('apiKey is not in the issued format');
if (!apiSecret) throw new Error('apiSecret is missing');
this.#apiKey = apiKey;
this.#apiSecret = apiSecret;
}
#bearer() {
const now = Math.floor(Date.now() / 1000);
if (!this.#token || now >= this.#tokenExp - RENEW_BEFORE) {
this.#tokenExp = now + TOKEN_LIFETIME;
this.#token = jwt.sign(
{ sub: this.#apiKey, iat: now, exp: this.#tokenExp }, // iat and exp are both required
this.#apiSecret,
{ algorithm: 'HS256', keyid: this.#apiKey }, // keyid becomes the kid header
);
}
return this.#token;
}
async #send(method, path, { query, body, timeoutMs = 30_000 } = {}) {
const url = new URL(BASE_URL + path);
for (const [key, value] of Object.entries(query ?? {})) url.searchParams.set(key, String(value));
const res = await fetch(url, {
method,
headers: {
Authorization: `Bearer ${this.#bearer()}`,
'X-Request-Id': randomUUID(),
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new ApiError(res.status, await res.json().catch(() => null), res.headers);
return res;
}
/** A JSON route. Resolves to the envelope: { success, message, data, pagination? }. */
async request(method, path, options) {
const json = await (await this.#send(method, path, options)).json();
// validate-me is the one JSON route that answers without an envelope.
return path === '/api-account/validate-me' ? { success: true, data: json } : json;
}
/** Every item of a paginated list, 100 per page (the maximum). */
async *paginate(path, query = {}) {
for (let page = 1; ; page++) {
const { data, pagination } = await this.request('GET', path, { query: { ...query, page, limit: 100 } });
yield* data;
if (!pagination || page >= pagination.totalPages) return;
}
}
/** A certificate or report download. Saves the file and resolves to its name. */
async download(path, directory = '.') {
const res = await this.#send('GET', path, { timeoutMs: 120_000 });
const bytes = Buffer.from(await res.arrayBuffer()); // rejects if the connection drops mid-body
const expected = Number(res.headers.get('content-length'));
const decoded = Boolean(res.headers.get('content-encoding')); // then Content-Length is the encoded size
if (expected && !decoded && bytes.length !== expected) throw new Error('Download ended early');
const name = basename(fileNameOf(res.headers.get('content-disposition')) ?? 'download.pdf');
await writeFile(join(directory, name), bytes);
return name;
}
}
function fileNameOf(header) {
const star = /filename\*=UTF-8''([^;]+)/i.exec(header ?? '');
if (star) return decodeURIComponent(star[1]);
return /filename="([^"]*)"/i.exec(header ?? '')?.[1] ?? null;
}
Using it:
import { MainTeamClient, ApiError } from './main-team-client.mjs';
const api = new MainTeamClient({
apiKey: process.env.MTO_API_KEY,
apiSecret: process.env.MTO_API_SECRET,
});
// 1. Credentials work? validate-me returns the bare account.
const { data: me } = await api.request('GET', '/api-account/validate-me');
console.log(`Signed in as ${me.companyName}`);
// 2. Organization ids, looked up once and cached.
const orgIds = {};
for await (const org of api.paginate('/organization')) orgIds[org.slug] = org._id;
// 3. Walk every student on the core record.
for await (const student of api.paginate('/student')) console.log(student._id, student.username);
// 4. One student's released certificates on stem, downloaded.
const studentId = '66f2b7c1e4a9d20012ab34cd'; // a core id, as registration returned it
try {
for await (const cert of api.paginate(`/${orgIds.stem}/certificate/${studentId}`)) {
await api.download(`/${orgIds.stem}/certificate/download/${cert._id}`, './exports');
}
} catch (error) {
if (!(error instanceof ApiError)) throw error;
console.error(error.status, error.code, error.message, `request_id=${error.requestId}`);
}
A minimal client in PHP
About 130 lines, for PHP 8.1 or newer with ext-curl. It needs one dependency:
composer require firebase/php-jwt
<?php
// MainTeamClient.php: a minimal Main Team API client (PHP 8.1+, ext-curl, firebase/php-jwt).
declare(strict_types=1);
use Firebase\JWT\JWT;
final class ApiError extends RuntimeException
{
public function __construct(
public readonly int $status,
public readonly string $errorCode,
string $message,
public readonly ?string $requestId = null,
public readonly ?int $retryAfter = null,
) {
parent::__construct($message);
}
}
final class MainTeamClient
{
private const BASE_URL = 'https://api.main-team.org/v1'; // sandbox: 'https://apisnd.main-team.org/v1'
private const TOKEN_LIFETIME = 900; // seconds; the API accepts at most 3600
private const RENEW_BEFORE = 60; // sign a new token this long before exp
private ?string $token = null;
private int $tokenExp = 0;
public function __construct(private readonly string $apiKey, private readonly string $apiSecret)
{
if (!preg_match('/^key_[A-Za-z0-9_-]{24}$/', $apiKey)) {
throw new InvalidArgumentException('apiKey is not in the issued format');
}
if ($apiSecret === '') {
throw new InvalidArgumentException('apiSecret is missing');
}
}
private function bearer(): string
{
$now = time();
if ($this->token === null || $now >= $this->tokenExp - self::RENEW_BEFORE) {
$this->tokenExp = $now + self::TOKEN_LIFETIME;
$this->token = JWT::encode(
['sub' => $this->apiKey, 'iat' => $now, 'exp' => $this->tokenExp],
$this->apiSecret,
'HS256',
$this->apiKey, // the fourth argument becomes the kid header
);
}
return $this->token;
}
/** One request. Returns [headers, body]; throws ApiError on a 4xx or 5xx answer. */
private function send(string $method, string $path, array $query = [], ?array $body = null, int $timeout = 30): array
{
$headers = [];
$ch = curl_init(self::BASE_URL . $path . ($query ? '?' . http_build_query($query) : ''));
$sent = ['Authorization: Bearer ' . $this->bearer(), 'X-Request-Id: ' . bin2hex(random_bytes(16))];
if ($body !== null) {
$sent[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_HTTPHEADER => $sent,
CURLOPT_HEADERFUNCTION => static function ($ch, string $line) use (&$headers): int {
if (str_contains($line, ':')) {
[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}
return strlen($line);
},
]);
$raw = curl_exec($ch); // false on a network error or a transfer cut short
if ($raw === false) {
throw new RuntimeException('Request failed: ' . curl_error($ch));
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status >= 400) {
$error = json_decode($raw, true)['error'] ?? [];
throw new ApiError(
$status,
$error['code'] ?? 'unknown',
$error['message'] ?? "HTTP $status",
$error['request_id'] ?? $headers['x-request-id'] ?? null,
isset($headers['retry-after']) ? (int) $headers['retry-after'] : null,
);
}
return [$headers, $raw];
}
/** A JSON route. Returns the envelope: success, message, data and maybe pagination. */
public function request(string $method, string $path, array $query = [], ?array $body = null): array
{
[, $raw] = $this->send($method, $path, $query, $body);
$json = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
// validate-me is the one JSON route that answers without an envelope.
return $path === '/api-account/validate-me' ? ['success' => true, 'data' => $json] : $json;
}
/** Every item of a paginated list, 100 per page (the maximum). */
public function paginate(string $path, array $query = []): Generator
{
for ($page = 1; ; $page++) {
$envelope = $this->request('GET', $path, ['page' => $page, 'limit' => 100] + $query);
foreach ($envelope['data'] as $item) {
yield $item;
}
if ($page >= ($envelope['pagination']['totalPages'] ?? 0)) {
return;
}
}
}
/** A certificate or report download. Saves the file and returns its name. */
public function download(string $path, string $directory = '.'): string
{
[$headers, $raw] = $this->send('GET', $path, timeout: 120);
if (isset($headers['content-length']) && strlen($raw) !== (int) $headers['content-length']) {
throw new RuntimeException('Download ended early');
}
$disposition = $headers['content-disposition'] ?? '';
$name = preg_match("/filename\\*=UTF-8''([^;]+)/i", $disposition, $m) ? rawurldecode($m[1])
: (preg_match('/filename="([^"]*)"/i', $disposition, $m) ? $m[1] : 'download.pdf');
$name = basename($name);
file_put_contents($directory . '/' . $name, $raw);
return $name;
}
}
Using it:
<?php
require __DIR__ . '/vendor/autoload.php';
require __DIR__ . '/MainTeamClient.php';
$api = new MainTeamClient(getenv('MTO_API_KEY'), getenv('MTO_API_SECRET'));
// 1. Credentials work? validate-me returns the bare account.
$me = $api->request('GET', '/api-account/validate-me')['data'];
echo "Signed in as {$me['companyName']}\n";
// 2. Organization ids, looked up once and cached.
$orgIds = [];
foreach ($api->paginate('/organization') as $org) {
$orgIds[$org['slug']] = $org['_id'];
}
// 3. One student's released reports on coding, downloaded.
$studentId = '66f2b7c1e4a9d20012ab34cd'; // a core id, as registration returned it
try {
foreach ($api->paginate("/{$orgIds['coding']}/report/{$studentId}") as $report) {
$api->download("/{$orgIds['coding']}/report/download/{$report['_id']}", '/var/exports');
}
} catch (ApiError $e) {
error_log("{$e->status} {$e->errorCode}: {$e->getMessage()} request_id={$e->requestId}");
}
This client buffers a download in memory before writing it. For very large files, point
CURLOPT_FILE at a temporary file instead, check the status, then rename the file into place.
What the minimal clients leave out
Add these before you run either client in production:
- Retries. Wrap
requestwith backoff for network errors and5xx, and a wait ofretryAfterseconds (or 60 when it's missing) for429. Follow the retry table in rule 9 above. - A 401 retry. On
401, drop the cached token, sign a new one, and retry once. A second401is a configuration problem. - Logging. Log method, path, status, duration and request id. Redact the
Authorizationheader, and never log a sign-in link URL. - Deprecation headers. Log any
DeprecationorSunsetresponse header so a route that is going away doesn't surprise you. Deprecations are announced at least 6 months ahead in the changelog. - A health check.
GET /v1/healthneeds no token and doesn't count against your rate limit. It answers{ "success": true, "message": "Request completed successfully.", "data": { "status": "ok" } }while the API is up. To check your own credentials as well, callvalidate-me.
Conformance checklist
Run through this list before you rely on your client. Each item is a rule the API enforces or a behavior it has.
Authentication
- The
apiSecretis read from an environment variable or secret store, on the server only, and never written to a log, an error report or a response. - Tokens are HS256, with
kid=apiKeyin the header andsub=apiKeyin the payload. - Every token has numeric
iatandexpin whole seconds, andexp − iatis at most 3600. - A token is reused until about 60 seconds before
exp, then replaced. - The server clock is synchronized.
iatis never more than 30 seconds ahead of real time. - A
401triggers at most one re-sign and retry, never a loop. - Revoking a token (
POST /v1/api-account/revoke-token) is wired up for shutdown or a suspected leak, if you keep long-lived tokens.
Requests
- The base URL is
https://api.main-team.org/v1(orhttps://apisnd.main-team.org/v1for the sandbox), fixed in configuration you control. - Organization paths use the
_idfromGET /v1/organization, never a slug. - Bodies are UTF-8 JSON with
Content-Type: application/json, under 100 kB. - Bodies carry only documented fields. A
400that names a property is treated as a bug in the caller. - Every request sends an
X-Request-Id(1 to 256 characters ofA–Z a–z 0–9 . _ : ; = + / @ -), or the response's id is logged. - Connect and read timeouts are set, with a longer read timeout for downloads.
Responses
- Success bodies are unwrapped from
{ success, message, data, pagination? }. -
validate-meis handled as a bare object. - A
404on a single read is treated as not found, whether the record is missing or not yours. -
201and200onPOST /applicationare both treated as success.200means the application already existed. - Error handling branches on status and
error.code, never onerror.message. - Unknown response fields and unknown enum values are ignored, not rejected.
- Students are matched on
mainId, not on an organization's_id.
Pagination
-
limitis at most 100, andpagestarts at 1. - Walking stops at
pagination.totalPages, including when it is 0. - Long walks de-duplicate by
_id.
Downloads
- The status is checked before any bytes are written. An error body is JSON.
- The file name comes from
filename*first, thenfilename, with any path stripped. - A short or interrupted body is treated as a failed download and the partial file discarded.
- A
404is treated as "not available": missing, not released, not yours and no file all give the same answer.
Rate limits and retries
- A
429waitsRetry-Afterseconds, or 60 when the header is missing, before that operation is called again. -
X-RateLimit-Remainingis used to pace bulk jobs, per operation. - Network errors and
5xxare retried with exponential backoff and jitter, on safe requests only. -
POST /v1/studentis never retried blindly. A409is resolved by fetching the named student. - Other
4xxanswers are not retried.
Sign-in links and security
- A sign-in link is minted when the student asks for it, and the browser is redirected at once.
- Link URLs are never stored, logged, emailed, retried or prefetched.
- The
Authorizationheader is redacted everywhere it could be logged. - Nothing that holds the
apiSecretruns in a browser or a distributed app.
Testing your client
Test against the sandbox first, with your sandbox credentials. In production, start with calls that change nothing:
GET /v1/health: reachability, no token needed.GET /v1/api-account/validate-me: token signing, and the bare-object exception.GET /v1/organization: the envelope and pagination.GET /v1/country?limit=500:limitclamping.pagination.limitcomes back as 100.- A token with
exp − iatof 3601 seconds: expect401 unauthorizedwithrequest_id. GET /v1/000000000000000000000000/exam: expect404 not_found,Organization not found!.
Every call you make against production counts against your rate limit and, for anything that writes, changes real data. The sandbox has its own accounts, seeded reference data, no emails and no real payments, so run the writes there. See Environments.
Stuck? The Troubleshooting page is organized by symptom, and Support says what to include when you write to us.