Skip to content
API documentation
View as MarkdownOpen in Claude

Tutorials

Register a student and enter them for an exam

In this tutorial you take one student from nothing to a confirmed exam entry. You sign a token, find the organization, register the student, send them into the organization once, choose an exam from the list the platform would offer them, and create the application. At the end you have one script in Node.js and one in PHP that do the whole run, with the output you should expect at each step.

Plan on about 20 minutes. Every step shows the raw request with curl first, so you can follow along in a terminal before you run the scripts.

Warning

Run it in the sandbox first. Against production, every write in this tutorial creates real records: a real student, a real sign-in and a real application, and no route deletes a student. Use your sandbox credentials and https://apisnd.main-team.org/v1 in place of https://api.main-team.org/v1 in the commands and in BASE_URL; see Environments. In production, run it only for a student you actually mean to enter, or agree a test student with your contact first.

What you will build

StepRequestWhy
1GET /v1/api-account/validate-meProve that your token is accepted
2GET /v1/organizationGet the organization's _id, which every organization route takes
3GET /v1/countryGet the country's _id, which registration needs
4POST /v1/studentRegister the student on the core record
5POST /v1/<organizationId>/auth/signinThe first sign-in creates the organization's own copy of the student
6GET /v1/<organizationId>/exam/available/<studentId>The exams this student can sit, as a tree
7POST /v1/<organizationId>/applicationEnter the student for one of them
8GET /v1/<organizationId>/application/student-applications/<studentId>Confirm the entry and its payment

The example uses the stem organization. Any organization works the same way: only its _id changes.

Before you start

What you need

  • Your apiKey (starts with key_) and apiSecret (starts with secret_), issued by an operator. See Authentication if you do not have them yet.
  • Node.js 20 or newer, or PHP 8.1 or newer with the curl and json extensions.
  • A browser, for step 5.
  • The student's details: name, email address, date of birth, sex, country, city, school and grade.

Permissions

Each request needs a permission on the organization it acts on. Routes without an organization in the path act on mto, the core record. Ask your operator for these roles if you do not hold them already; see Permissions for how roles are written.

PermissionOnUsed in
api/*mtoStep 1
organization/readmtoStep 2
country/readmtoStep 3 (and grade/read if you look grades up)
student/createmtoStep 4
auth/signinstemStep 5
exam/readstemStep 6
application/createstemStep 7
application/readstemSteps 5 and 8

A missing permission answers 403 with the code forbidden and the message Insufficient role permissions. Retrying or signing a new token does not help; only an operator can add the role.

Set your credentials

Keep both values out of source control. The scripts below read them from the environment:

export MTO_API_KEY='key_Q2xpZW50RXhhbXBsZUtleTAx'
export MTO_API_SECRET='secret_…'

Security

The apiSecret signs tokens that act as your whole account. Keep it on your server. Never put it in a browser, a mobile app, a log line or a support ticket. If it leaks, contact your operator at once: see Handle tokens in production.

The helper file

Both scripts use a small helper that signs tokens and sends requests. Save it next to the script. It does five things, each explained in more depth elsewhere in these docs:

  • It signs an HS256 token with your secret, puts your apiKey in the kid header and in sub, and sets iat and exp 15 minutes apart. It reuses the token until a minute before it expires. See Handle tokens in production.
  • It sends an X-Request-Id with every request, so an error can be traced. See Requests and responses.
  • On 429 too_many_requests it waits for the number of seconds in Retry-After, then retries up to three times. See Rate limits.
  • On 401 unauthorized it signs a fresh token and retries once.
  • Anything else that is not a success becomes an ApiError carrying the status, the code, the message and the request_id.
// mainteam.mjs: a minimal Main Team API client. Node.js 20 or newer, no dependencies.
import { createHmac, randomUUID } from 'node:crypto';

export const BASE_URL = 'https://api.main-team.org/v1'; // sandbox: 'https://apisnd.main-team.org/v1'

const API_KEY = process.env.MTO_API_KEY;
const API_SECRET = process.env.MTO_API_SECRET;
if (!API_KEY || !API_SECRET) {
  throw new Error('Set MTO_API_KEY and MTO_API_SECRET first.');
}

const TOKEN_LIFETIME = 900; // seconds; the API accepts at most 3600
const RENEW_MARGIN = 60; // mint a fresh token this long before exp

const b64url = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
let cached = null;

/** A signed token, reused until a minute before it expires. */
export function token() {
  const now = Math.floor(Date.now() / 1000);
  if (cached && cached.exp - RENEW_MARGIN > now) return cached.token;
  const header = b64url({ alg: 'HS256', typ: 'JWT', kid: API_KEY });
  const payload = b64url({ sub: API_KEY, iat: now, exp: now + TOKEN_LIFETIME });
  const signature = createHmac('sha256', API_SECRET)
    .update(`${header}.${payload}`)
    .digest('base64url');
  cached = { token: `${header}.${payload}.${signature}`, exp: now + TOKEN_LIFETIME };
  return cached.token;
}

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

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Sends one request. Returns the parsed JSON body, or the raw Response when
 * `raw` is set (for file downloads). A 429 is retried after Retry-After, up to
 * three times; a 401 is retried once with a fresh token. Anything else that is
 * not 2xx throws ApiError.
 */
export async function api(method, path, body, { raw = false, timeoutMs = 30_000 } = {}) {
  const requestId = randomUUID();
  let renewed = false;
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(BASE_URL + path, {
      method,
      headers: {
        Authorization: `Bearer ${token()}`,
        'X-Request-Id': requestId,
        ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
      },
      body: body === undefined ? undefined : JSON.stringify(body),
      signal: AbortSignal.timeout(timeoutMs),
    });
    if (res.status === 429 && attempt <= 3) {
      await res.text();
      await sleep((Number(res.headers.get('retry-after')) || 1) * 1000);
      continue;
    }
    if (res.status === 401 && !renewed) {
      await res.text();
      renewed = true;
      cached = null;
      continue;
    }
    if (raw && res.ok) return res;
    const text = await res.text();
    let json = null;
    try {
      json = text ? JSON.parse(text) : null;
    } catch {
      json = null; // not JSON, for example an error page from a proxy
    }
    if (!res.ok) throw new ApiError(res.status, json, res.headers.get('x-request-id'));
    return json;
  }
}
<?php
// mainteam.php: a minimal Main Team API client. PHP 8.1 or newer with ext-curl and ext-json.
declare(strict_types=1);

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);
    }
}

final class MainTeam
{
    public 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_MARGIN = 60;    // mint a fresh token this long before exp

    private ?string $token = null;
    private int $tokenExp = 0;

    public function __construct(private string $apiKey, private string $apiSecret)
    {
    }

    public static function fromEnv(): self
    {
        $key = getenv('MTO_API_KEY');
        $secret = getenv('MTO_API_SECRET');
        if (!$key || !$secret) {
            throw new RuntimeException('Set MTO_API_KEY and MTO_API_SECRET first.');
        }
        return new self($key, $secret);
    }

    public static function b64url(string $bytes): string
    {
        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
    }

    /** A signed token, reused until a minute before it expires. */
    public function token(): string
    {
        $now = time();
        if ($this->token !== null && $this->tokenExp - self::RENEW_MARGIN > $now) {
            return $this->token;
        }
        $header = self::b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $this->apiKey]));
        $payload = self::b64url(json_encode(['sub' => $this->apiKey, 'iat' => $now, 'exp' => $now + self::TOKEN_LIFETIME]));
        $signature = self::b64url(hash_hmac('sha256', "$header.$payload", $this->apiSecret, true));
        $this->token = "$header.$payload.$signature";
        $this->tokenExp = $now + self::TOKEN_LIFETIME;
        return $this->token;
    }

    /**
     * Sends one request and returns the decoded JSON body. A 429 is retried
     * after Retry-After, up to three times; a 401 is retried once with a fresh
     * token. Anything else that is not 2xx throws ApiError.
     */
    public function call(string $method, string $path, ?array $body = null): ?array
    {
        $requestId = bin2hex(random_bytes(16));
        $renewed = false;
        for ($attempt = 1; ; $attempt++) {
            [$status, $headers, $text] = $this->send($method, $path, $body, $requestId);
            if ($status === 429 && $attempt <= 3) {
                sleep(max(1, (int) ($headers['retry-after'] ?? '1')));
                continue;
            }
            if ($status === 401 && !$renewed) {
                $renewed = true;
                $this->token = null;
                continue;
            }
            $json = json_decode($text, true);
            if ($status < 200 || $status >= 300) {
                $error = is_array($json) ? ($json['error'] ?? []) : [];
                throw new ApiError(
                    $status,
                    $error['code'] ?? 'unknown',
                    $error['message'] ?? "HTTP $status",
                    $error['request_id'] ?? ($headers['x-request-id'] ?? null),
                );
            }
            return is_array($json) ? $json : null;
        }
    }

    /** @return array{0: int, 1: array<string, string>, 2: string} */
    private function send(string $method, string $path, ?array $body, string $requestId): array
    {
        $headers = [];
        $httpHeaders = ['Authorization: Bearer ' . $this->token(), 'X-Request-Id: ' . $requestId];
        $ch = curl_init(self::BASE_URL . $path);
        if ($body !== null) {
            $httpHeaders[] = 'Content-Type: application/json';
            curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES));
        }
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_HTTPHEADER => $httpHeaders,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 30,
            CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$headers): int {
                $parts = explode(':', $line, 2);
                if (count($parts) === 2) {
                    $headers[strtolower(trim($parts[0]))] = trim($parts[1]);
                }
                return strlen($line);
            },
        ]);
        $text = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        $error = curl_error($ch);
        curl_close($ch);
        if ($text === false) {
            throw new RuntimeException("Network error: $error");
        }
        return [$status, $headers, (string) $text];
    }
}

A token for curl

To follow the curl examples, sign a token in your shell. This needs only openssl, and the token lasts 15 minutes:

b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
NOW=$(date +%s)
HEADER=$(printf '{"alg":"HS256","typ":"JWT","kid":"%s"}' "$MTO_API_KEY" | b64url)
PAYLOAD=$(printf '{"sub":"%s","iat":%d,"exp":%d}' "$MTO_API_KEY" "$NOW" $((NOW + 900)) | b64url)
SIG=$(printf '%s.%s' "$HEADER" "$PAYLOAD" | openssl dgst -sha256 -hmac "$MTO_API_SECRET" -binary | b64url)
export TOKEN="$HEADER.$PAYLOAD.$SIG"

Step 1: check your credentials

Start with the one request that tells you whether your token works and which roles your account holds.

curl -s https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"
{
  "_id": "66d0c2f4a1b2c3d4e5f60718",
  "apiKey": "key_Q2xpZW50RXhhbXBsZUtleTAx",
  "companyName": "Example Schools Ltd",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "api/*", "target": "mto" },
    { "effect": "allow", "action": "*/read", "target": "*" },
    { "effect": "allow", "action": "student/*", "target": "mto" },
    { "effect": "allow", "action": "auth/signin", "target": "stem" },
    { "effect": "allow", "action": "application/*", "target": "stem" }
  ],
  "isActive": true
}

This is the one JSON response that is not wrapped in the usual { success, message, data } envelope: the account comes back bare. Your secret is never part of it.

If this fails:

  • 401 unauthorized means the token was refused. Every reason gets the same message on purpose, so work through the checklist in Authentication: key and secret the right way round, kid and sub both equal to your apiKey, iat and exp in seconds and at most 3600 apart, and your clock within 30 seconds of real time.
  • 403 forbidden means the token is fine but your account lacks api/* on mto. The rest of this tutorial can still work; ask your operator to add the role anyway, because it is also what lets you revoke tokens.

Step 2: find the organization's _id

Every organization route takes the organization's _id in the path, never its slug. List the organizations and pick the one you want by slug:

curl -s "https://api.main-team.org/v1/organization?limit=100" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Organizations fetched successfully.",
  "data": [
    {
      "_id": "64b7f1c2a9e3d45f10c2b7a1",
      "name": "STEM Olympiad",
      "slug": "stem",
      "logo": "https://…/stem.svg",
      "desc": "…",
      "defaultRedirect": "https://my.stemolympiad.org"
    },
    {
      "_id": "64b7f1c2a9e3d45f10c2b7a2",
      "name": "Hi-Lingua",
      "slug": "hilingua",
      "logo": "https://…/hilingua.svg",
      "desc": "…"
    }
  ],
  "pagination": { "page": 1, "limit": 100, "total": 5, "totalPages": 1 }
}

The list holds the five organizations that run exams (abridged above). mto, the core record, is not in it: the routes without an organization id, such as registration in step 4, act on it.

An organization's _id does not change, so look it up once and keep it in your configuration. If you put the slug in the path by mistake (/v1/stem/exam), the answer is 404 not_found with the message Organization not found!. See Organizations for the difference between the core record and the organizations.

Step 3: look up the country

Registration takes the country as an _id. Countries are paginated like every list, at most 100 per page, so walk the pages until you find the one you want. Here the student lives in Germany (DE):

curl -s "https://api.main-team.org/v1/country?page=1&limit=100" \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Countries fetched successfully.",
  "data": [
    {
      "_id": "630e0182c53dc79a6836e67e",
      "name": "GERMANY",
      "iso2": "DE",
      "iso3": "DEU",
      "dialCode": "+49"
    }
  ],
  "pagination": { "page": 1, "limit": 100, "total": 247, "totalPages": 3 }
}

A few things about the other three location fields, so you know what you can skip:

  • Some countries cannot be selected. They are left out of this list, and registering a student with one of their ids answers 400 with country is not a known country.
  • grade accepts a name. Send "10" rather than an id if that is what your records hold. The names are the ones GET /v1/grade lists; the ids are the same on every organization. An unknown grade is a 400, never a new grade.
  • city and school accept names. A city name is looked up inside the country you send, and a school name inside that country and city. Names are matched without regard to case.
  • Nothing is ever created. A city or school the platform does not know is a 400 naming the field, for example school is not a known school. This API does not create reference data. Ask your contact to add missing schools rather than guessing a spelling.

See Reference data for caching advice.

Step 4: register the student

Send the student's details to the core record:

curl -s -X POST https://api.main-team.org/v1/student \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Jane",
    "lastName": "Doe",
    "email": "jane.doe@example.com",
    "birth": "14/05/2011",
    "sex": "f",
    "country": "630e0182c53dc79a6836e67e",
    "grade": "10",
    "city": "Berlin",
    "school": "Berlin International School"
  }'
FieldRequiredRule
firstNameyesA non-empty string
lastNameyesA non-empty string
emailyesA real email address, unused anywhere on the platform
birthyesDD/MM/YYYY, for example 14/05/2011, and a date that exists. 2011-05-14 and 31/02/2011 are refused
sexyesm, f or n
countryyesA country _id from step 3
gradeyesA grade _id or name
cityyesA city _id, or a name within country
schoolyesA school _id, or a name within country and city
phonenoA string
activatedPlatformsThisSeasonnoWhich organizations the student may use: common for all of them, or the slugs stem, hilingua, neo, gmath and coding. Leave it out and it is ["common"]; null is refused with 400
passwordnoNeeds auth/signin on mto as well. Leave it out: step 5 signs the student in without one. See Passwords

Any other field is refused with 400, naming it: property username should not exist. Send only the fields above, not a whole record copied out of your own system.

{
  "success": true,
  "message": "User registered successfully.",
  "data": {
    "_id": "66e6a3f5c1d2b30012a4f9c1",
    "username": "XXB1045",
    "firstName": "Jane",
    "lastName": "Doe",
    "fullName": "Jane Doe",
    "email": "jane.doe@example.com",
    "emailConfirmed": false,
    "birth": "14/05/2011",
    "sex": "f",
    "country": "630e0182c53dc79a6836e67e",
    "city": "63a1d9e0b4c5a6f700112233",
    "school": "64f2e1d0c3b4a59600aabbcc",
    "grade": "630e01826836e67ec53dc7a6",
    "activatedPlatformsThisSeason": ["common"],
    "createdAt": "2026-09-15T10:18:02.117Z",
    "updatedAt": "2026-09-15T10:18:02.117Z"
  }
}

What the response tells you:

  • _id is the student's core id. Store it against your own record of the student. It is the <studentId> every later request takes, on every organization. See Identifiers.
  • username is minted for you and cannot be changed. It is what the student and support staff quote.
  • fullName is derived from firstName and lastName; you never send it.
  • emailConfirmed starts as false. That does not stop anything in this tutorial.
  • The four location fields come back as the ids they resolved to. GET /v1/student/<studentId> returns them as full documents.

When registration is refused

StatusMessageWhat to do
409 conflictA student with that email is already registered to this account (66e6a3f5c1d2b30012a4f9c1).You registered this student before. Use the id in the message.
409 conflictThat email address is already registered.Another account holds the address. It cannot be used; ask the student for another.
400 bad_requestbirth must be a real date in DD/MM/YYYY formatFix the named field and send again. The message names one problem at a time.
400 bad_requestgrade is not a known grade. This API does not create reference data.Use a name or id from GET /v1/grade.
403 forbiddenSetting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.Leave password out.

Warning

Registration is not idempotent. A repeat is refused with 409, not answered with the first result. If a registration times out and you do not know whether it went through, send the same request again. A 409 that names an id tells you it did, and which id it got. Do not put registration in a blind retry loop. See Retries and idempotency.

Step 5: send the student in once

The core record is the student's identity. Each organization also keeps its own copy of the student, and that copy is created the first time the student signs in to that organization. Applications, certificates, reports and supervisor links all hang off that copy, so the student has to sign in once before you can enter them for anything.

Ask for a sign-in link:

curl -s -X POST https://api.main-team.org/v1/<organizationId>/auth/signin \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "studentId": "66e6a3f5c1d2b30012a4f9c1" }'
{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=q8x2…&redirectUrl=…",
    "organization": "stem",
    "studentId": "66e6a3f5c1d2b30012a4f9c1",
    "expiresIn": 120
  }
}

The URL signs whoever opens it in as the student. It is valid for 120 seconds and works once. For this tutorial, open it in a private browser window straight away. In your product, the student opens it from your site: Send a student into the panel shows how to do that safely, and Sign-in links has the full rules.

The student does not need a password or a confirmed email address for the link to work.

Check that the organization has its copy

The per-student application list is a convenient test. Until the organization holds a copy, it answers 409:

curl -s "https://api.main-team.org/v1/<organizationId>/application/student-applications/66e6a3f5c1d2b30012a4f9c1?limit=1" \
  -H "Authorization: Bearer $TOKEN"
{
  "error": {
    "code": "conflict",
    "message": "Student has never signed in to stem, so stem holds no record for them. Generate a sign-in link first with POST /:organizationId/auth/signin.",
    "documentation_url": "https://hub.main-team.org/api/errors#conflict",
    "request_id": "3f0c1f5e-8a0b-4c1e-9a53-0d6b1c2e7f41"
  }
}

After the panel has loaded, the same request answers 200 with an empty list. If it still answers 409, the link was not followed in time or was already used. Ask for a new one.

StatusMessageWhat to do
403 forbiddenStudent is not activated for organization stem.The student's activatedPlatformsThisSeason does not include common or stem. See Send a student into the panel.
403 forbiddenInsufficient role permissionsYour account lacks auth/signin on this organization.
404 not_foundStudent not found!The id is not one of your students. A student registered by another account answers exactly like one that does not exist.

Step 6: fetch the student's exam picker

Now ask which exams this student can sit. The answer is the same list the student would see in the panel's "new application" window, as one tree: category, then sitting, then language.

curl -s https://api.main-team.org/v1/<organizationId>/exam/available/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Available exams fetched successfully.",
  "data": [
    {
      "_id": "64c0a1b2c3d4e5f601234501",
      "name": "Mathematics",
      "order": 1,
      "isActive": true,
      "sessions": [
        {
          "_id": "64c0a1b2c3d4e5f601234601",
          "sessionName": "November sitting",
          "date": "2026-11-14T09:00:00.000Z",
          "sessionAlias": "Round 1",
          "sessionNote": "Doors open 30 minutes before the start.",
          "languages": [
            {
              "_id": "64c0a1b2c3d4e5f601234701",
              "name": "English",
              "code": "en",
              "order": 1,
              "matchedExam": {
                "_id": "64c0a1b2c3d4e5f601234801",
                "session": "64c0a1b2c3d4e5f601234601",
                "category": "64c0a1b2c3d4e5f601234501",
                "language": "64c0a1b2c3d4e5f601234701",
                "grades": ["630e01826836e67ec53dc7a6"],
                "countries": [],
                "price": 25,
                "examTime": 75
              }
            }
          ]
        }
      ]
    }
  ]
}

How to read it:

  • Each level is the full document it stands for (a category, a sitting, a language), plus the list of the level below.
  • Each leaf carries matchedExam, the one exam that category, sitting and language add up to. matchedExam._id is what you send in step 7. Inside matchedExam, session, category and language are ids, because the documents are the levels above it.
  • price is absent when an exam has no price. Such an application is created as free, so read the price as price ?? 0.

What the tree leaves out, so that nothing in it can be refused in step 7:

  • exams that are not accepting applications, whose sitting has already started, or whose category is not active;
  • exams not set for the student's grade. Grade is a hard filter;
  • exams restricted to countries other than the student's. An exam that names no countries is open everywhere;
  • exams with no language set;
  • any category and sitting the student already holds, in every language, because nobody can sit two papers in one category at once.

An empty data array means nothing is open to this student right now. A student with no grade gets 400 bad_request instead, because no exam could ever accept them. The tree works even before the student's first sign-in. See Exams for the full rules.

Step 7: create the application

Send the student's core id and the matchedExam._id you picked. That is the whole body:

curl -s -X POST https://api.main-team.org/v1/<organizationId>/application \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "studentId": "66e6a3f5c1d2b30012a4f9c1", "examId": "64c0a1b2c3d4e5f601234801" }'
{
  "success": true,
  "message": "Application created successfully.",
  "data": {
    "_id": "66e6a4b1c1d2b30012a4fa07",
    "exam": "64c0a1b2c3d4e5f601234801",
    "user": "66e6a41fc1d2b30012a4f9f3",
    "payment": "66e6a4b1c1d2b30012a4fa09",
    "partners": [],
    "createdAt": "2026-09-15T10:21:37.412Z",
    "updatedAt": "2026-09-15T10:21:37.412Z"
  }
}

Two things to notice:

  • user is not your studentId. An application lives in the organization, so it points at the organization's copy of the student. Step 8 shows how to match it back.
  • A payment comes with every application. For a free exam it is created as paid with amount0. For a priced exam it is pending for the exam's price. This API never charges or refunds.

Creating the same application again is safe. A second request for the same student and the same exam answers 200 with Application already exists. and the application you already have. So if this request times out, send it again. The repeat is checked against the same rules as the first request, so once the exam has closed for applications, or the student's grade or country no longer fits it, a repeat answers 409 like any other refusal. Read the student's applications (step 8) to confirm an entry rather than creating it again weeks later.

When the application is refused

The exam you send has to be one the picker in step 6 would offer this student. If it is not, the answer is 409 conflict and the message says which rule refused it:

Message (starts with)Meaning
Student has never signed in to stem…Step 5 has not happened yet
Exam is not open for application.Applications are closed, the sitting has started, or the category is not active
Exam is not available for grade 9. It accepts grade 10, 11.The student's grade does not fit
Exam is not available in this student’s country. It is offered in …The exam is restricted to other countries
Exam has no language set…The exam cannot be sat, so it is not offered
Exam is not available to this student.…The exam fails the picker for another reason; choose one from step 6
Student already has an application for Mathematics on this sitting.…Another exam in the same category on the same sitting is already held

The checks run in a fixed order: the student (404), the organization's copy (409), the exam's existence (404), then the rules above. A request that fails several rules reports the first.

Other answers:

StatusMessageMeaning
400 bad_requestStudent has no grade set…Set a grade first
400 bad_requestexamId must be a mongodb idThe body is malformed
404 not_foundExam not found!No exam with this id exists on this organization
404 not_foundStudent not found!Not one of your students

Branch on the status and the code. The messages are there for people, and their wording may improve. See Applications for every rule.

Step 8: confirm the entry

List the student's applications on this organization:

curl -s https://api.main-team.org/v1/<organizationId>/application/student-applications/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN"
{
  "success": true,
  "message": "Applications fetched successfully.",
  "data": [
    {
      "_id": "66e6a4b1c1d2b30012a4fa07",
      "exam": {
        "_id": "64c0a1b2c3d4e5f601234801",
        "session": "64c0a1b2c3d4e5f601234601",
        "category": "64c0a1b2c3d4e5f601234501",
        "language": "64c0a1b2c3d4e5f601234701",
        "price": 25
      },
      "user": {
        "_id": "66e6a41fc1d2b30012a4f9f3",
        "mainId": "66e6a3f5c1d2b30012a4f9c1",
        "firstName": "Jane",
        "lastName": "Doe"
      },
      "payment": {
        "_id": "66e6a4b1c1d2b30012a4fa09",
        "amount": 25,
        "status": "pending"
      },
      "partners": []
    }
  ],
  "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}

exam, user and payment come back as documents. user._id is the organization's copy; user.mainId is the core id you stored in step 4, so always match rows to your records on mainId.

The complete script

Both scripts do steps 1 to 8 in order and stop at the first error, printing its status, code, message and request id. Edit the student's details at the top, then run the script with the organization slug. The script registers the student, or reuses them if this account registered them before. It prints a sign-in link and waits for you to open it, then applies for the first exam in the student's picker.

Node.js

// register-and-apply.mjs: register a student, send them in once, enter them for an exam.
// Usage: node register-and-apply.mjs stem
import readline from 'node:readline/promises';
import { api, ApiError } from './mainteam.mjs';

const slug = process.argv[2] ?? 'stem';

// The student to register. Replace with real data from your own records.
const countryIso2 = 'AL';
const newStudent = {
  firstName: 'Jane',
  lastName: 'Doe',
  email: 'jane.doe@example.com',
  birth: '14/05/2011',
  sex: 'f',
  grade: '10',
  city: 'Berlin',
  school: 'Berlin International School',
};

async function findCountryId(iso2) {
  for (let page = 1; ; page++) {
    const res = await api('GET', `/country?page=${page}&limit=100`);
    const hit = res.data.find((country) => country.iso2 === iso2);
    if (hit) return hit._id;
    if (page >= res.pagination.totalPages) throw new Error(`Country ${iso2} cannot be selected.`);
  }
}

async function registerStudent(fields) {
  try {
    const res = await api('POST', '/student', fields);
    console.log(`   registered as ${res.data.username}`);
    return res.data._id;
  } catch (error) {
    // Already registered to this account: the message names the existing _id.
    const id =
      error instanceof ApiError && error.status === 409
        ? error.message.match(/\(([0-9a-f]{24})\)/)?.[1]
        : undefined;
    if (id) {
      console.log('   already registered to this account; reusing it');
      return id;
    }
    throw error;
  }
}

// The per-student list answers 409 until the organization holds a copy of the student.
async function hasOrgCopy(orgId, studentId) {
  try {
    await api('GET', `/${orgId}/application/student-applications/${studentId}?limit=1`);
    return true;
  } catch (error) {
    if (error instanceof ApiError && error.status === 409) return false;
    throw error;
  }
}

async function ensureSignedInOnce(orgId, studentId) {
  if (await hasOrgCopy(orgId, studentId)) {
    console.log('5. The student has signed in to this organization before');
    return;
  }
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  try {
    for (;;) {
      const link = await api('POST', `/${orgId}/auth/signin`, { studentId });
      console.log(`5. Open this link in a browser within ${link.data.expiresIn} s. It works once:`);
      console.log(`   ${link.data.url}`);
      await rl.question('   Press Enter after the panel has loaded... ');
      if (await hasOrgCopy(orgId, studentId)) return;
      console.log('   The organization has no record of the student yet. Minting a fresh link.');
    }
  } finally {
    rl.close();
  }
}

function firstLeaf(tree) {
  for (const category of tree) {
    for (const session of category.sessions) {
      for (const language of session.languages) {
        return { category, session, language, exam: language.matchedExam };
      }
    }
  }
  return null;
}

async function main() {
  const account = await api('GET', '/api-account/validate-me');
  console.log(`1. Signed in as ${account.companyName} (${account.apiKey})`);

  const orgs = await api('GET', '/organization?limit=100');
  const org = orgs.data.find((candidate) => candidate.slug === slug);
  if (!org) throw new Error(`No organization with slug ${slug}.`);
  console.log(`2. ${slug} is ${org._id}`);

  const countryId = await findCountryId(countryIso2);
  console.log(`3. Country ${countryIso2} is ${countryId}`);

  const studentId = await registerStudent({ ...newStudent, country: countryId });
  console.log(`4. Student ${studentId}`);

  await ensureSignedInOnce(org._id, studentId);

  const tree = (await api('GET', `/${org._id}/exam/available/${studentId}`)).data;
  const leaf = firstLeaf(tree);
  if (!leaf) {
    console.log('6. No exam is open to this student right now.');
    return;
  }
  const price = leaf.exam.price ?? 0;
  console.log(
    `6. Picked ${leaf.category.name} on ${leaf.session.date} in ${leaf.language.name} ` +
      `(exam ${leaf.exam._id}, price ${price})`,
  );

  const created = await api('POST', `/${org._id}/application`, { studentId, examId: leaf.exam._id });
  console.log(`7. ${created.message} Application ${created.data._id}`);

  const list = await api('GET', `/${org._id}/application/student-applications/${studentId}`);
  for (const application of list.data) {
    const payment = application.payment ?? {};
    console.log(`8. ${application._id}: payment ${payment.status}, amount ${payment.amount}`);
  }
}

main().catch((error) => {
  if (error instanceof ApiError) {
    console.error(`${error.status} ${error.code}: ${error.message} (request ${error.requestId})`);
  } else {
    console.error(error);
  }
  process.exitCode = 1;
});

PHP

<?php
// register-and-apply.php: register a student, send them in once, enter them for an exam.
// Usage: php register-and-apply.php stem
declare(strict_types=1);
require __DIR__ . '/mainteam.php';

$slug = $argv[1] ?? 'stem';

// The student to register. Replace with real data from your own records.
$countryIso2 = 'AL';
$newStudent = [
    'firstName' => 'Jane',
    'lastName' => 'Doe',
    'email' => 'jane.doe@example.com',
    'birth' => '14/05/2011',
    'sex' => 'f',
    'grade' => '10',
    'city' => 'Berlin',
    'school' => 'Berlin International School',
];

function findCountryId(MainTeam $api, string $iso2): string
{
    for ($page = 1; ; $page++) {
        $res = $api->call('GET', "/country?page=$page&limit=100");
        foreach ($res['data'] as $country) {
            if (($country['iso2'] ?? null) === $iso2) {
                return $country['_id'];
            }
        }
        if ($page >= $res['pagination']['totalPages']) {
            throw new RuntimeException("Country $iso2 cannot be selected.");
        }
    }
}

function registerStudent(MainTeam $api, array $fields): string
{
    try {
        $res = $api->call('POST', '/student', $fields);
        echo "   registered as {$res['data']['username']}\n";
        return $res['data']['_id'];
    } catch (ApiError $e) {
        // Already registered to this account: the message names the existing _id.
        if ($e->status === 409 && preg_match('/\(([0-9a-f]{24})\)/', $e->getMessage(), $m)) {
            echo "   already registered to this account; reusing it\n";
            return $m[1];
        }
        throw $e;
    }
}

// The per-student list answers 409 until the organization holds a copy of the student.
function hasOrgCopy(MainTeam $api, string $orgId, string $studentId): bool
{
    try {
        $api->call('GET', "/$orgId/application/student-applications/$studentId?limit=1");
        return true;
    } catch (ApiError $e) {
        if ($e->status === 409) {
            return false;
        }
        throw $e;
    }
}

function ensureSignedInOnce(MainTeam $api, string $orgId, string $studentId): void
{
    if (hasOrgCopy($api, $orgId, $studentId)) {
        echo "5. The student has signed in to this organization before\n";
        return;
    }
    while (true) {
        $link = $api->call('POST', "/$orgId/auth/signin", ['studentId' => $studentId]);
        echo "5. Open this link in a browser within {$link['data']['expiresIn']} s. It works once:\n";
        echo "   {$link['data']['url']}\n";
        echo '   Press Enter after the panel has loaded... ';
        fgets(STDIN);
        if (hasOrgCopy($api, $orgId, $studentId)) {
            return;
        }
        echo "   The organization has no record of the student yet. Minting a fresh link.\n";
    }
}

function firstLeaf(array $tree): ?array
{
    foreach ($tree as $category) {
        foreach ($category['sessions'] as $session) {
            foreach ($session['languages'] as $language) {
                return [
                    'category' => $category,
                    'session' => $session,
                    'language' => $language,
                    'exam' => $language['matchedExam'],
                ];
            }
        }
    }
    return null;
}

$api = MainTeam::fromEnv();

try {
    $account = $api->call('GET', '/api-account/validate-me');
    echo "1. Signed in as {$account['companyName']} ({$account['apiKey']})\n";

    $orgs = $api->call('GET', '/organization?limit=100');
    $orgId = null;
    foreach ($orgs['data'] as $candidate) {
        if ($candidate['slug'] === $slug) {
            $orgId = $candidate['_id'];
        }
    }
    if ($orgId === null) {
        throw new RuntimeException("No organization with slug $slug.");
    }
    echo "2. $slug is $orgId\n";

    $countryId = findCountryId($api, $countryIso2);
    echo "3. Country $countryIso2 is $countryId\n";

    $studentId = registerStudent($api, $newStudent + ['country' => $countryId]);
    echo "4. Student $studentId\n";

    ensureSignedInOnce($api, $orgId, $studentId);

    $tree = $api->call('GET', "/$orgId/exam/available/$studentId")['data'];
    $leaf = firstLeaf($tree);
    if ($leaf === null) {
        echo "6. No exam is open to this student right now.\n";
        exit(0);
    }
    $price = $leaf['exam']['price'] ?? 0;
    echo "6. Picked {$leaf['category']['name']} on {$leaf['session']['date']} in {$leaf['language']['name']}"
        . " (exam {$leaf['exam']['_id']}, price $price)\n";

    $created = $api->call('POST', "/$orgId/application", [
        'studentId' => $studentId,
        'examId' => $leaf['exam']['_id'],
    ]);
    echo "7. {$created['message']} Application {$created['data']['_id']}\n";

    $list = $api->call('GET', "/$orgId/application/student-applications/$studentId");
    foreach ($list['data'] as $application) {
        $status = $application['payment']['status'] ?? '-';
        $amount = $application['payment']['amount'] ?? '-';
        echo "8. {$application['_id']}: payment $status, amount $amount\n";
    }
} catch (ApiError $e) {
    fwrite(STDERR, "{$e->status} {$e->errorCode}: {$e->getMessage()} (request {$e->requestId})\n");
    exit(1);
}

Expected output

A first run for a new student looks like this (ids shortened, and the username written with XX in place of the country's code):

1. Signed in as Example Schools Ltd (key_Q2xpZW50RXhhbXBsZUtleTAx)
2. stem is 64b7f1c2a9e3d45f10c2b7a1
3. Country AL is 630e0182c53dc79a6836e67e
   registered as XXB1045
4. Student 66e6a3f5c1d2b30012a4f9c1
5. Open this link in a browser within 120 s. It works once:
   https://auth.main-team.org/api/user/oauth/invoke?accessToken=q8x2…&redirectUrl=…
   Press Enter after the panel has loaded...
6. Picked Mathematics on 2026-11-14T09:00:00.000Z in English (exam 64c0a1b2…801, price 25)
7. Application created successfully. Application 66e6a4b1…fa07
8. 66e6a4b1…fa07: payment pending, amount 25

Run it a second time and it skips the work already done: step 4 reuses the student, step 5 reports that the student has signed in before, and step 6 no longer offers Mathematics on the November sitting, because the student now holds it.

   already registered to this account; reusing it
4. Student 66e6a3f5c1d2b30012a4f9c1
5. The student has signed in to this organization before
6. No exam is open to this student right now.

When something goes wrong

You seeLikely causeFix
401 unauthorized on every requestThe token is refusedCheck key and secret, and your clock. See Authentication
403 forbidden, Insufficient role permissionsA role is missingCompare the step with the permission table above; ask your operator
404 not_found, Organization not found!A slug or a wrong id in the pathUse the _id from step 2
400 bad_request, property … should not existAn extra field in the bodySend only the documented fields
409 conflict on step 4The email is already registeredUse the id in the message, or another address
409 conflict, Student has never signed in… on step 7The organization has no copy yetDo step 5, and open the link within 120 s
429 too_many_requestsMore than 100 requests to one route in 60 sWait for Retry-After seconds; the helper already does. See Rate limits
500 internal_errorA fault on our sideRetry later. If it persists, send the request_id to support

Every error body carries a request_id. Quote it when you ask for help; it lets us find the exact request. See Troubleshooting for more symptoms.

Next steps

Search the API documentation

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