Skip to content
API documentation
View as MarkdownOpen in Claude

Concepts

Security

Your API account acts on behalf of real students, many of them children and teenagers. With it you can read their records, enter them for exams, and send a browser into their panel already signed in. This page explains how to protect the credentials that make that possible, how to limit what a leak could do, and what to do if something goes wrong.

Your two credentials

apiKeyapiSecret
Looks likekey_ followed by 24 characterssecret_ followed by 43 characters
What it doesNames your account. It goes in the kid header and the sub claim of every token.Signs your tokens. The API verifies every token against it.
Secret?No, but there is no reason to publish it.Yes. Anyone who holds it can act as your account.
Where you get itOnce, when your account is createdOnce, when your account is created

The apiSecret is shown once, when the account is created. It cannot be displayed again, recovered, or rotated. If you lose it, or it leaks, the way forward is a new account (see If something leaks).

Danger

Never put your apiSecret anywhere a person or program outside your server can read it: not in source control, a browser page, a mobile app, a desktop app, a spreadsheet or a support ticket. Never send it to anyone, including us. Support never needs it: a request_id is enough to find a request.

There is no exception: we never ask for it, on any page, and support never needs it.

Where to keep the secret

  • Keep it in a secret manager, or in an environment variable injected at runtime. Keep it out of files in your repository, including .env files that are committed or copied into images.
  • Give read access only to the process that signs tokens.
  • Use the secret as it was given to you: the whole string, including secret_, as UTF-8 bytes. Don't trim it, decode it or re-encode it. A secret that picked up a trailing newline or quotes from a config file signs tokens the API refuses.

Call the API from your server only

The production API sends no CORS headers, so a web page on another origin cannot call it. A page that could would have to carry your apiSecret, or tokens signed with it, to every visitor. Build your integration server to server. Browsers talk to your server, and your server talks to the API. The sandbox accepts browser requests from these documentation pages only.

The interactive "Try it" console on these pages works only against the sandbox, and takes a token, never your secret. Sign the token locally with your sandbox credentials and paste only the token. The console refuses anything that looks like an apiSecret.

Keep tokens short-lived

You sign your own tokens (see Authentication). A token is a bearer credential: whoever holds it can call the API as your account, within your roles, until it expires. The API accepts a lifetime of at most 3600 seconds, plus 30 seconds of clock tolerance. Shorter is better:

  • Give tokens a lifetime of 15 minutes or less. Reuse one until about a minute before it expires, then sign a new one.
  • Keep tokens in memory. Don't write them to disk, a shared cache or a database.
  • A token is signed, not encrypted. Anyone who holds one can read its header and claims, including your apiKey.
// token.mjs: sign and reuse a short-lived token (npm install jsonwebtoken)
import jwt from 'jsonwebtoken';

const API_KEY = process.env.MTO_API_KEY;
const API_SECRET = process.env.MTO_API_SECRET;
const LIFETIME_SECONDS = 15 * 60;
let cached = null;

export function getToken({ forceNew = false } = {}) {
  const now = Math.floor(Date.now() / 1000);
  if (!forceNew && cached && cached.exp - 60 > now) return cached.value;

  const exp = now + LIFETIME_SECONDS;
  const value = jwt.sign({ sub: API_KEY, iat: now, exp }, API_SECRET, {
    algorithm: 'HS256',
    keyid: API_KEY,
  });
  cached = { value, exp };
  return value;
}
<?php
// token.php: sign and reuse a short-lived token (composer require firebase/php-jwt)
use Firebase\JWT\JWT;

function apiToken(bool $forceNew = false): string
{
    static $cached = null;
    $now = time();
    if (!$forceNew && $cached !== null && $cached['exp'] - 60 > $now) {
        return $cached['value'];
    }
    $apiKey = getenv('MTO_API_KEY');
    $exp = $now + 15 * 60;
    $value = JWT::encode(['sub' => $apiKey, 'iat' => $now, 'exp' => $exp], getenv('MTO_API_SECRET'), 'HS256', $apiKey);
    $cached = ['value' => $value, 'exp' => $exp];
    return $value;
}

Redact the Authorization header in every log, trace and error report your HTTP client produces. Do the same for sign-in link URLs, which carry their token in the query string:

const SENSITIVE_HEADERS = new Set(['authorization', 'cookie']);

export function redactHeaders(headers) {
  return Object.fromEntries(
    Object.entries(headers).map(([name, value]) =>
      SENSITIVE_HEADERS.has(name.toLowerCase()) ? [name, '[redacted]'] : [name, value],
    ),
  );
}

export function redactUrl(href) {
  const url = new URL(href);
  if (url.searchParams.has('accessToken')) url.searchParams.set('accessToken', '[redacted]');
  return url.toString();
}

Revoke a token you no longer trust

POST /v1/api-account/revoke-token revokes the token you send it with. The token stops working at once, for every request. Your other tokens and your apiSecret are unaffected.

curl -X POST https://api.main-team.org/v1/api-account/revoke-token \
  -H "Authorization: Bearer $TOKEN_TO_REVOKE"
{
  "success": true,
  "message": "Token revoked successfully",
  "data": { "expiresIn": 874 }
}

expiresIn is how many seconds the revocation is kept: the token's remaining lifetime plus the 30-second tolerance. After that the token would be refused for being expired anyway. The call needs the api/* permission. Revoke a token when it may have been exposed, for example when it appeared in a log, or when a server that held it is being retired.

If something leaks

What leakedWhat to do
A tokenRevoke it with revoke-token, using that token. If you can't, it stops working 30 seconds after its exp, and no token's exp can be more than an hour after its iat.
Your apiSecretEmail info@main-team.org straight away and ask for the account to be deactivated. Revoking tokens is not enough, because whoever holds the secret can sign new ones.
A sign-in linkA link works once and for 120 seconds, and there is no way to cancel one early. If you think somebody other than the student opened it, tell us.

When the account is deactivated, every token signed with its secret stops working within 60 seconds and answers 401 unauthorized.

New credentials come as a new account. Students belong to the account that registered them, and a new account does not see the old one's students. Agree with us how to continue before you switch.

When you write to us, include:

  • your apiKey (never the secret or a token);
  • when you think the leak happened, in UTC;
  • the request_id of any request you don't recognize, from your own logs.

Ask for the least access you need

Every operation needs a permission, and an operator grants permissions to your account as roles. A role is { effect, action, target, authorized }: allow or disallow, an action such as application/create, an organization or *, and normally an empty authorized. The full grammar is in Permissions. Ask for the actions and organizations your integration actually uses, and no more:

Your integration needs toRoles to ask for (effect action target)
Check its credentialsallow api/* mto
Read reference data (countries, grades, organizations)allow */read mto
Register and update studentsallow student/* mto
Enter students for exams on stemallow student/* stem · allow exam/read stem · allow exam-category/read stem · allow application/* stem
Collect results on stemallow certificate/read stem · allow report/read stem
Send students into the panel on stemallow auth/signin stem
Set students' passwordsallow auth/signin mto
Never delete an application anywheredisallow application/delete *

A disallow beats any allow it matches, which makes it a cheap safety net. disallow application/delete * means no bug in your code can ever delete an application.

auth/signin is the most powerful permission there is. With it, your account can sign a browser in as any of your students, or set a password they sign in with. It is never included in student/* or api/*. Ask for it only on the organizations where you send students into the panel, and on mto only if you set passwords.

To see which roles your account holds, call GET /v1/api-account/validate-me. It needs api/*, and it answers with the bare account, without the usual envelope:

curl https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"
{
  "_id": "66b2d0c4e1a9f3b27c8d4e10",
  "apiKey": "key_Q2x5dW1uYXJ5LWV4YW1wbGUx",
  "companyName": "Example Learning Ltd",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "api/*", "target": "mto" },
    { "effect": "allow", "action": "student/*", "target": "mto" },
    { "effect": "allow", "action": "application/*", "target": "stem" },
    { "effect": "disallow", "action": "application/delete", "target": "*" }
  ],
  "isActive": true
}

Note

Roles belong to the account, not to a server. Splitting one integration over several accounts doesn't help either: students belong to the account that registered them, and no account can see another's students. Keep one account per student population, and keep its secret on as few machines as possible.

POST /v1/{organizationId}/auth/signin returns a URL that signs one of your students into one organization's panel:

{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=k3v9q0…&redirectUrl=%2Fdashboard",
    "organization": "stem",
    "studentId": "652f1c9b8e4b2a0012a3c4d5",
    "expiresIn": 120
  }
}

Whoever opens that URL first is signed in as the student. Treat it like a password that lasts two minutes:

  • Check who is asking. The API trusts you to send the right person. Before you mint a link, make sure the user on your side is signed in to your system and is the student the link is for.
  • Mint on click, redirect at once. Answer the student's click with a 302 to the URL. Don't generate links ahead of time or in bulk.
  • Never send it anywhere else. No email, SMS, chat or push notification. Link previews and security scanners fetch URLs on their own and would use the link up, or sign themselves in.
  • Never log it. The token is in the query string.
  • Keep redirect to a path. The optional redirect must be a path on the organization's own site, starting with a single /, such as /dashboard. Full URLs are refused, so a link can't send a student to another site.

A link works once, and only for 120 seconds (expiresIn). The API only mints links for students your account owns. A student of another account gets the same 404 not_found as one that doesn't exist. The student also needs access to that organization, or you get 403 forbidden with a message naming the organization. An unconfirmed email address does not block a link, but once the student lands, the panel asks them to confirm it before they can use the panel. See Sign-in links and Send a student to the panel.

Passwords

A password you set is a sign-in that doesn't expire. Prefer sign-in links: they last two minutes, work once, and leave nothing behind for anyone to steal.

If you do set passwords, through POST /v1/student or PUT /v1/student/{studentId}/password:

  • Your account needs auth/signin on mto. Without it, a registration carrying a password is refused with 403 forbidden before anything is written.
  • Generate a random password for each student. The API refuses passwords shorter than 5 characters, longer than 72 bytes, or containing the student's first name, surname, username, email address or the part of the address before the @. Fragments shorter than 4 characters don't count. Never derive a password from the student's record: usernames are not secret.
  • Deliver the password to the student over a channel you trust, and don't keep the plain text afterwards. The API stores only a hash, and no operation ever returns a password, not even the one that set it.
  • Once a student has confirmed their email address, the account is theirs. The password route then answers 409 conflict, and you should send them a sign-in link instead.
import { randomBytes } from 'node:crypto';
// 24 URL-safe characters from a secure random source: well inside 72 bytes.
const password = randomBytes(18).toString('base64url');
<?php
// 24 URL-safe characters from a secure random source: well inside 72 bytes.
$password = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');

If the API answers 400 because a generated password happens to contain part of the student's details, generate another one. See Passwords.

Students' personal data

This page gives no legal advice. Follow the data-protection rules that apply to you and to the students' schools. Some practical habits help whatever those rules are:

  • Send only what the API needs. The API refuses any field it doesn't declare with 400, so you can't send it more than the documented fields anyway. Don't copy whole records out of your own system.
  • Keep only what you use. Every student record comes back with a fixed set of profile fields, never a password or anything about your account. Store the fields you need, for as long as you need them.
  • Keep personal data out of URLs and request ids. No operation takes personal data in a query string. Don't put names or email addresses in X-Request-Id, which is repeated in error bodies and logs.
  • Treat certificates and reports as personal documents. They carry a student's results. Store them encrypted, restrict who can open them, and delete them when you no longer need them.
  • Limit access inside your organization. The API only ever shows your account its own students. A record your account didn't register answers exactly like one that doesn't exist. Hold your own staff to the same need-to-know standard.

Transport

  • Always use https://. API responses carry Strict-Transport-Security, so browsers and many HTTP clients will insist on it anyway.
  • Never turn off certificate verification (curl -k, CURLOPT_SSL_VERIFYPEER => false, rejectUnauthorized: false). A client that accepts any certificate sends your tokens to whoever answers.
  • Keep your TLS library and your operating system's certificate store up to date.

What the API does on its side

These properties hold for every request, so you can rely on them:

  • Every authentication failure looks the same: one 401 with one message, whatever was wrong. A leaked key or token tells its finder nothing about whether it is live.
  • Only HS256 tokens are accepted, and only with a lifetime of at most an hour.
  • Records outside your account look missing. Another account's student, application, certificate or report answers 404 exactly like a record that doesn't exist.
  • Unknown request fields are refused, never silently ignored.
  • Responses tell browsers not to sniff content, frame or run anything, through X-Content-Type-Options, X-Frame-Options and a Content-Security-Policy that allows nothing to load.
  • The request log is minimal. For each request it records the request id, method, path without the query string, status, duration and your account id. Headers, tokens, query strings and bodies are not written to it.

Reporting a security problem

If you find a security issue in the API or in these pages, email info@main-team.org with a subject starting Security:. Include:

  • what you found, and how to reproduce it;
  • the request_ids and UTC times of the requests involved;
  • your apiKey, if your account was involved.

Never include your apiSecret, a token or a sign-in link, even an expired one. Please give us a chance to fix the issue before you tell anyone else about it. See Support.

Checklist

  • The apiSecret lives in a secret store or runtime environment, readable only by the signing process.
  • Nothing calls the API from a browser or an app. Every call comes from your server.
  • Tokens live 15 minutes or less and stay in memory.
  • Authorization headers and sign-in URLs are redacted in every log.
  • You know how to revoke a token, and whom to email if the secret leaks.
  • Your roles cover what you use and nothing more, with a disallow on anything you never do.
  • Sign-in links are minted only for your own signed-in user, on click, and followed at once.
  • Passwords, if you set any, are random per student and never kept in plain text.
  • Certificates and reports are stored as personal documents.
  • TLS certificate verification is on everywhere.

Search the API documentation

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