Skip to content
API documentation
View as MarkdownOpen in Claude

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:

  1. The rules every client has to follow, with the reason for each.
  2. A minimal client in Node.js (fetch and jsonwebtoken, about 90 lines) and in PHP (curl and firebase/php-jwt, about 130 lines).
  3. 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:

PartFieldValue
HeaderalgHS256. No other algorithm is accepted.
HeadertypJWT. Usual, but not checked.
HeaderkidYour apiKey, exactly as issued: key_ followed by 24 characters from A–Z a–z 0–9 _ -.
PayloadsubYour apiKey again. It must equal kid.
PayloadiatIssued-at time, in whole seconds since the Unix epoch. Required.
PayloadexpExpiry time, in seconds since the epoch. Required, after iat, and at most 3600 seconds after it.
PayloadnbfOptional. 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/v1 in production and https://apisnd.main-team.org/v1 in the sandbox. HTTPS only.
  • Organization routes take the organization's _id, a 24-character hexadecimal id from GET /v1/organization, never its slug. Look the ids up once and cache them. An id that names no organization answers 404 not_found with Organization 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 with 413 payload_too_large before the token is even read. A character set or content encoding the API can't read is 415 unsupported_media_type. Uncompressed bodies are accepted, and so are bodies sent with Content-Encoding: gzip, deflate or br.
  • 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-Type is the stored type, usually application/pdf.
  • Content-Disposition is attachment; filename="<ascii>"; filename*=UTF-8''<percent-encoded>. Prefer filename*, which carries the real name, including non-ASCII characters. Fall back to filename only when filename* is missing. Either way, strip any path before you write the file.
  • Content-Length is present when the size is known.
  • An error arrives before any bytes, as the usual JSON error with a 4xx or 5xx status. 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:

HeaderMeaning
X-RateLimit-LimitRequests allowed per window for this operation.
X-RateLimit-RemainingRequests left in the current window.
X-RateLimit-ResetSeconds 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

RequestSafe to retry?
Any GETYes.
POST /v1/{organizationId}/applicationYes, for the same exam: a repeat answers 200 "Application already exists." with the existing application.
PUT on a studentYes: 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/studentNo. A repeat answers 409 conflict naming the existing student. Fetch that student instead.
POST /v1/{organizationId}/auth/signinMint 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 request with backoff for network errors and 5xx, and a wait of retryAfter seconds (or 60 when it's missing) for 429. 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 second 401 is a configuration problem.
  • Logging. Log method, path, status, duration and request id. Redact the Authorization header, and never log a sign-in link URL.
  • Deprecation headers. Log any Deprecation or Sunset response 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/health needs 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, call validate-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 apiSecret is 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 = apiKey in the header and sub = apiKey in the payload.
  • Every token has numeric iat and exp in whole seconds, and exp − iat is at most 3600.
  • A token is reused until about 60 seconds before exp, then replaced.
  • The server clock is synchronized. iat is never more than 30 seconds ahead of real time.
  • A 401 triggers 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 (or https://apisnd.main-team.org/v1 for the sandbox), fixed in configuration you control.
  • Organization paths use the _id from GET /v1/organization, never a slug.
  • Bodies are UTF-8 JSON with Content-Type: application/json, under 100 kB.
  • Bodies carry only documented fields. A 400 that names a property is treated as a bug in the caller.
  • Every request sends an X-Request-Id (1 to 256 characters of A–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-me is handled as a bare object.
  • A 404 on a single read is treated as not found, whether the record is missing or not yours.
  • 201 and 200 on POST /application are both treated as success. 200 means the application already existed.
  • Error handling branches on status and error.code, never on error.message.
  • Unknown response fields and unknown enum values are ignored, not rejected.
  • Students are matched on mainId, not on an organization's _id.

Pagination

  • limit is at most 100, and page starts 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, then filename, with any path stripped.
  • A short or interrupted body is treated as a failed download and the partial file discarded.
  • A 404 is treated as "not available": missing, not released, not yours and no file all give the same answer.

Rate limits and retries

  • A 429 waits Retry-After seconds, or 60 when the header is missing, before that operation is called again.
  • X-RateLimit-Remaining is used to pace bulk jobs, per operation.
  • Network errors and 5xx are retried with exponential backoff and jitter, on safe requests only.
  • POST /v1/student is never retried blindly. A 409 is resolved by fetching the named student.
  • Other 4xx answers are not retried.
  • 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 Authorization header is redacted everywhere it could be logged.
  • Nothing that holds the apiSecret runs 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:

  1. GET /v1/health: reachability, no token needed.
  2. GET /v1/api-account/validate-me: token signing, and the bare-object exception.
  3. GET /v1/organization: the envelope and pagination.
  4. GET /v1/country?limit=500: limit clamping. pagination.limit comes back as 100.
  5. A token with exp − iat of 3601 seconds: expect 401 unauthorized with request_id.
  6. GET /v1/000000000000000000000000/exam: expect 404 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.

Search the API documentation

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