# Build your own client

> Everything a Main Team API client must do, a minimal client in Node.js and in PHP, and a checklist to test your own against.

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](https://hub.main-team.org/api/reference) for routes and fields, and the
[error reference](https://hub.main-team.org/api/errors) for codes.

## The rules

### Rule 1: you sign your own token

There is no token endpoint. You create a JSON Web Token yourself and sign it with your `apiSecret`:

| Part | Field | Value |
| --- | --- | --- |
| Header | `alg` | `HS256`. No other algorithm is accepted. |
| Header | `typ` | `JWT`. Usual, but not checked. |
| Header | `kid` | Your `apiKey`, exactly as issued: `key_` followed by 24 characters from `A–Z a–z 0–9 _ -`. |
| Payload | `sub` | Your `apiKey` again. It must equal `kid`. |
| Payload | `iat` | Issued-at time, in whole seconds since the Unix epoch. **Required.** |
| Payload | `exp` | Expiry time, in seconds since the epoch. **Required**, after `iat`, and at most **3600** seconds after it. |
| Payload | `nbf` | Optional. If you set it, it's enforced with the same 30-second tolerance. Most clients leave it out. |

Send the token as `Authorization: Bearer <token>`. The server allows **30 seconds** of clock
difference: a token is still accepted up to 30 seconds after its `exp`, and its `iat` may be up to
30 seconds ahead of the server's clock. An `iat` further ahead is refused, so keep your server
clock synchronized.

**Reuse a token** until about a minute before its `exp`, then sign a new one. Signing is cheap,
but a token per request adds nothing. Each of your servers can sign its own token: the rate limit
belongs to your account, not to a token.

**Every authentication failure is the same `401 unauthorized`,** with the same message:
`Authentication is required or the provided credentials are invalid.` A missing header, a wrong
key, a deactivated account, a bad signature, a missing `iat`, an expired token, an over-long token
and a revoked token all look alike. The server doesn't say which check failed, so a leaked key or
token tells whoever found it nothing. Check your token against the table above, and quote the
`request_id` when you ask for help. See [Authentication](https://hub.main-team.org/api/authentication).

To end a token early, call [`POST /v1/api-account/revoke-token`](https://hub.main-team.org/api/reference/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):

```json
{
  "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:

```json
{
  "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`](https://hub.main-team.org/api/reference/get-current-api-account)
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](https://hub.main-team.org/api/requests-and-responses) and [Errors](https://hub.main-team.org/api/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://hub.main-team.org/api/environments#sandbox). HTTPS only.
- Organization routes take the organization's `_id`, a 24-character hexadecimal id from
  [`GET /v1/organization`](https://hub.main-team.org/api/reference/list-organizations), 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](https://hub.main-team.org/api/pagination).

### Rule 6: downloads are not JSON

[Certificate](https://hub.main-team.org/api/reference/download-certificate) and [report](https://hub.main-team.org/api/reference/download-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](https://hub.main-team.org/api/permissions).

### Rule 8: 429 means wait

Each account may make **100 requests per 60 seconds to each operation**, wherever the requests
come from, and each operation is counted on its own. Counted responses carry:

| Header | Meaning |
| --- | --- |
| `X-RateLimit-Limit` | Requests allowed per window for this operation. |
| `X-RateLimit-Remaining` | Requests left in the current window. |
| `X-RateLimit-Reset` | Seconds until the window resets. |

A `429 too_many_requests` carries `Retry-After`, in seconds. Wait that long. Requests sent sooner
are refused too, though they don't extend the block, and a block never lasts longer than one
window. Requests refused before they're counted don't use up your budget. These are a `401`, a
`403` for a missing permission, an unknown organization, and a body refused as too large or
unreadable. A request refused later, such as a `400` for a bad field, is counted. See
[Rate limits](https://hub.main-team.org/api/rate-limits).

### Rule 9: retry only what is safe

| Request | Safe to retry? |
| --- | --- |
| Any `GET` | Yes. |
| `POST /v1/{organizationId}/application` | Yes, for the same exam: a repeat answers `200 "Application already exists."` with the existing application. |
| `PUT` on a student | Yes: repeating it writes the same values. |
| `PUT /v1/{organizationId}/application/{applicationId}` | Yes: once the move has happened, a repeat answers `200` `"Application already uses that exam."` |
| `DELETE /v1/{organizationId}/application/{applicationId}` | Yes, but the second call answers `404 not_found` because the first one worked. |
| `POST /v1/student` | **No.** A repeat answers `409 conflict` naming the existing student. Fetch that student instead. |
| `POST /v1/{organizationId}/auth/signin` | Mint a new link instead. **Never** retry, fetch or prefetch a link URL: it works once. |

Retry network errors and `5xx` answers, on requests that are safe to repeat, with exponential
backoff and jitter. Don't retry `4xx`
answers, with two exceptions: `429` after `Retry-After`, and a `401` once after signing a fresh
token, in case yours expired mid-flight. See
[Retries and idempotency](https://hub.main-team.org/api/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:

```bash
npm install jsonwebtoken
```

```js
// 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:

```js
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:

```bash
composer require firebase/php-jwt
```

```php
<?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
<?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](https://hub.main-team.org/api/changelog).
- **A health check.** [`GET /v1/health`](https://hub.main-team.org/api/reference/get-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.

### Sign-in links and security

- [ ] A sign-in link is minted when the student asks for it, and the browser is redirected at once.
- [ ] Link URLs are never stored, logged, emailed, retried or prefetched.
- [ ] The `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](https://hub.main-team.org/api/environments#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](https://hub.main-team.org/api/environments#sandbox).

Stuck? The [Troubleshooting](https://hub.main-team.org/api/troubleshooting) page is organized by symptom, and
[Support](https://hub.main-team.org/api/support) says what to include when you write to us.
