# Register a student and enter them for an exam

> A complete run from token to application. Register a student, send them in once, choose an exam from their picker and apply, in Node.js and PHP.

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.

**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](https://hub.main-team.org/api/environments#sandbox). 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

| Step | Request | Why |
|---|---|---|
| 1 | `GET /v1/api-account/validate-me` | Prove that your token is accepted |
| 2 | `GET /v1/organization` | Get the organization's `_id`, which every organization route takes |
| 3 | `GET /v1/country` | Get the country's `_id`, which registration needs |
| 4 | `POST /v1/student` | Register the student on the core record |
| 5 | `POST /v1/<organizationId>/auth/signin` | The first sign-in creates the organization's own copy of the student |
| 6 | `GET /v1/<organizationId>/exam/available/<studentId>` | The exams this student can sit, as a tree |
| 7 | `POST /v1/<organizationId>/application` | Enter the student for one of them |
| 8 | `GET /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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/permissions) for how roles are written.

| Permission | On | Used in |
|---|---|---|
| `api/*` | `mto` | Step 1 |
| `organization/read` | `mto` | Step 2 |
| `country/read` | `mto` | Step 3 (and `grade/read` if you look grades up) |
| `student/create` | `mto` | Step 4 |
| `auth/signin` | `stem` | Step 5 |
| `exam/read` | `stem` | Step 6 |
| `application/create` | `stem` | Step 7 |
| `application/read` | `stem` | Steps 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:

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

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](https://hub.main-team.org/api/tutorials/token-handling#when-the-secret-itself-leaks).

### 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](https://hub.main-team.org/api/tutorials/token-handling).
- It sends an `X-Request-Id` with every request, so an error can be traced. See
  [Requests and responses](https://hub.main-team.org/api/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](https://hub.main-team.org/api/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`.

```js [mainteam.mjs]
// 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]
<?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:

```bash
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.

```bash [curl]
curl -s https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200]
{
  "_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](https://hub.main-team.org/api/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`:

```bash [curl]
curl -s "https://api.main-team.org/v1/organization?limit=100" \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200]
{
  "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](https://hub.main-team.org/api/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`):

```bash [curl]
curl -s "https://api.main-team.org/v1/country?page=1&limit=100" \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200 (abridged)]
{
  "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](https://hub.main-team.org/api/guides/reference-data) for caching advice.

## Step 4: register the student

Send the student's details to the core record:

```bash [curl]
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"
  }'
```

| Field | Required | Rule |
|---|---|---|
| `firstName` | yes | A non-empty string |
| `lastName` | yes | A non-empty string |
| `email` | yes | A real email address, unused anywhere on the platform |
| `birth` | yes | `DD/MM/YYYY`, for example `14/05/2011`, and a date that exists. `2011-05-14` and `31/02/2011` are refused |
| `sex` | yes | `m`, `f` or `n` |
| `country` | yes | A country `_id` from step 3 |
| `grade` | yes | A grade `_id` or name |
| `city` | yes | A city `_id`, or a name within `country` |
| `school` | yes | A school `_id`, or a name within `country` and `city` |
| `phone` | no | A string |
| `activatedPlatformsThisSeason` | no | Which 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` |
| `password` | no | Needs `auth/signin` on `mto` as well. Leave it out: step 5 signs the student in without one. See [Passwords](https://hub.main-team.org/api/guides/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.

```json [Response 201]
{
  "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](https://hub.main-team.org/api/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

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

**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](https://hub.main-team.org/api/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:

```bash [curl]
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" }'
```

```json [Response 200]
{
  "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](https://hub.main-team.org/api/tutorials/send-student-to-panel) shows
how to do that safely, and [Sign-in links](https://hub.main-team.org/api/guides/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`:

```bash [curl]
curl -s "https://api.main-team.org/v1/<organizationId>/application/student-applications/66e6a3f5c1d2b30012a4f9c1?limit=1" \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 409, before the first sign-in]
{
  "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.

### When the link is refused

| Status | Message | What to do |
|---|---|---|
| `403 forbidden` | `Student is not activated for organization stem.` | The student's `activatedPlatformsThisSeason` does not include `common` or `stem`. See [Send a student into the panel](https://hub.main-team.org/api/tutorials/send-student-to-panel#when-the-link-is-refused). |
| `403 forbidden` | `Insufficient role permissions` | Your account lacks `auth/signin` on this organization. |
| `404 not_found` | `Student 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**.

```bash [curl]
curl -s https://api.main-team.org/v1/<organizationId>/exam/available/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200 (abridged)]
{
  "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](https://hub.main-team.org/api/guides/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:

```bash [curl]
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" }'
```

```json [Response 201 (abridged)]
{
  "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 `amount`
  `0`. 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:

| Status | Message | Meaning |
|---|---|---|
| `400 bad_request` | `Student has no grade set…` | Set a grade first |
| `400 bad_request` | `examId must be a mongodb id` | The body is malformed |
| `404 not_found` | `Exam not found!` | No exam with this id exists on this organization |
| `404 not_found` | `Student 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](https://hub.main-team.org/api/guides/applications) for every rule.

## Step 8: confirm the entry

List the student's applications on this organization:

```bash [curl]
curl -s https://api.main-team.org/v1/<organizationId>/application/student-applications/66e6a3f5c1d2b30012a4f9c1 \
  -H "Authorization: Bearer $TOKEN"
```

```json [Response 200 (abridged)]
{
  "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

```js [register-and-apply.mjs]
// 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]
<?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):

```text
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.

```text
   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 see | Likely cause | Fix |
|---|---|---|
| `401 unauthorized` on every request | The token is refused | Check key and secret, and your clock. See [Authentication](https://hub.main-team.org/api/authentication) |
| `403 forbidden`, `Insufficient role permissions` | A role is missing | Compare the step with the permission table above; ask your operator |
| `404 not_found`, `Organization not found!` | A slug or a wrong id in the path | Use the `_id` from step 2 |
| `400 bad_request`, `property … should not exist` | An extra field in the body | Send only the documented fields |
| `409 conflict` on step 4 | The email is already registered | Use the id in the message, or another address |
| `409 conflict`, `Student has never signed in…` on step 7 | The organization has no copy yet | Do step 5, and open the link within 120 s |
| `429 too_many_requests` | More than 100 requests to one route in 60 s | Wait for `Retry-After` seconds; the helper already does. See [Rate limits](https://hub.main-team.org/api/rate-limits) |
| `500 internal_error` | A fault on our side | Retry later. If it persists, send the `request_id` to [support](https://hub.main-team.org/api/support) |

Every error body carries a `request_id`. Quote it when you ask for help; it lets us find the exact
request. See [Troubleshooting](https://hub.main-team.org/api/troubleshooting) for more symptoms.

## Next steps

- [Send a student into the panel](https://hub.main-team.org/api/tutorials/send-student-to-panel): the production version of step 5.
- [Change an application](https://hub.main-team.org/api/tutorials/change-an-application): move this entry to another language or sitting.
- [Collect results](https://hub.main-team.org/api/tutorials/collect-results): fetch certificates and reports once they are released.
- [Students](https://hub.main-team.org/api/guides/students), [Exams](https://hub.main-team.org/api/guides/exams) and [Applications](https://hub.main-team.org/api/guides/applications): every rule behind these steps.
- Reference: [registerStudent](https://hub.main-team.org/api/reference/register-student), [createSigninLink](https://hub.main-team.org/api/reference/create-signin-link), [listAvailableExams](https://hub.main-team.org/api/reference/list-available-exams), [createApplication](https://hub.main-team.org/api/reference/create-application).
