Start here
Quickstart
This page takes you from "I have credentials" to "I have a working token and the ids I need for every other call". You will:
- Keep your credentials on your server.
- Sign a token (Node.js, PHP or bash).
- Call
GET /v1/api-account/validate-meto prove the token works. - Call
GET /v1/organizationto get the organization ids that every organization route needs.
Plan on about ten minutes. Every command below runs against production, https://api.main-team.org/v1, so read Environments before you write any data.
Before you start
You need four things:
| You need | Why | Where it comes from |
|---|---|---|
An apiKey | Names your account. It is not secret. | Issued by an operator, together with the secret |
An apiSecret | Signs your tokens. It is secret and shown to you only once. | Issued by an operator |
| Roles on your account | Every route needs a permission. For this page you need api/* and organization/read, both on mto. | Set by an operator. See Permissions |
| A server with a correct clock | Tokens carry timestamps, and the API allows only 30 seconds of clock difference | NTP on your server |
There is no public sign-up. Access is by arrangement: write to info@main-team.org to request an account. Say which organizations you work with and what your integration will do, so the operator can give you the right roles from the start (the ready-made role profiles in Permissions are a good way to ask).
An apiKey looks like key_7fQx2LmN9pRtVw3YzA1bC4dE: key_ followed by exactly 24 characters. An apiSecret starts with secret_.
Security
The apiSecret is shown once, when the account is created. It cannot be recovered later, and there is no way to change it on an existing account. Put it in your secret store straight away. Never commit it, never put it in a browser or mobile app, and never paste it into a website, including online JWT debuggers.
Step 1: Store your credentials
Keep both values in your server's environment or secret manager, never in source code. The examples on this page read them from two environment variables:
export MTO_API_KEY='key_7fQx2LmN9pRtVw3YzA1bC4dE'
export MTO_API_SECRET='secret_...' # the full value, including the "secret_" prefix
export MTO_API_BASE='https://api.main-team.org/v1'
Use the secret exactly as it was issued: the whole string, secret_ prefix included, with no trailing newline or spaces. If you read it from a file, trim the line ending. A secret with an extra newline signs tokens that the API refuses.
Step 2: Sign a token
The API has no login endpoint. You sign your own short-lived token, a JSON Web Token (JWT), with your apiSecret, and send it as a bearer token. The token must have:
- the header
algset toHS256andkidset to yourapiKey, - the claim
subset to yourapiKey(the same value askid), - the claims
iat(issued at) andexp(expires), both in seconds since the Unix epoch, withexpat most 3600 seconds afteriat.
Authentication explains each rule, and has token caching and a full troubleshooting checklist. The shortest working versions are below.
Node.js
npm install jsonwebtoken
// mint-token.js
const jwt = require('jsonwebtoken');
const apiKey = process.env.MTO_API_KEY;
const apiSecret = process.env.MTO_API_SECRET;
const iat = Math.floor(Date.now() / 1000); // seconds, not milliseconds
const token = jwt.sign(
{ sub: apiKey, iat, exp: iat + 3600 },
apiSecret,
{ algorithm: 'HS256', keyid: apiKey }, // keyid becomes the "kid" header
);
console.log(token);
export TOKEN=$(node mint-token.js)
PHP
composer require firebase/php-jwt
<?php
// mint-token.php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
$apiKey = getenv('MTO_API_KEY');
$apiSecret = getenv('MTO_API_SECRET');
$iat = time();
$token = JWT::encode(
['sub' => $apiKey, 'iat' => $iat, 'exp' => $iat + 3600],
$apiSecret,
'HS256',
$apiKey // the fourth argument becomes the "kid" header
);
echo $token, PHP_EOL;
export TOKEN=$(php mint-token.php)
bash and openssl
This is handy for a first test from a terminal. For production code, use a JWT library.
b64url() { openssl base64 -e -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 + 3600))" | b64url)
signature=$(printf '%s.%s' "$header" "$payload" \
| openssl dgst -sha256 -hmac "$MTO_API_SECRET" -binary | b64url)
export TOKEN="$header.$payload.$signature"
Step 3: Check your token with validate-me
GET /v1/api-account/validate-me returns the account your token belongs to. It is the simplest way to prove that your key, your secret, your signing code and your clock all work.
curl -i "$MTO_API_BASE/api-account/validate-me" \
-H "Authorization: Bearer $TOKEN"
A working token gets 200:
HTTP/2 200
content-type: application/json; charset=utf-8
x-request-id: 3f2a9c1e-6b7d-4e8f-9a0b-1c2d3e4f5a6b
x-ratelimit-limit: 100
x-ratelimit-remaining: 99
x-ratelimit-reset: 60
{
"_id": "66f1a2b3c4d5e6f7a8b9c0d1",
"apiKey": "key_7fQx2LmN9pRtVw3YzA1bC4dE",
"companyName": "Northwind Learning Ltd",
"scopes": [],
"roles": [
{ "effect": "allow", "action": "api/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "mto" },
{ "effect": "allow", "action": "student/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "stem" },
{ "effect": "allow", "action": "application/*", "target": "stem" },
{ "effect": "allow", "action": "auth/signin", "target": "stem" }
],
"isActive": true
}
Two things to notice:
- This response has no envelope.
validate-meis the one JSON route that returns the object on its own. Every other JSON route wraps its payload in{ "success", "message", "data" }, which you will see in the next step. See Requests and responses. rolesis your permission list. Read it now. If a later call answers403, this list is where you find out why. Permissions explains how to read it.
The X-Request-Id header is on every response, successful or not. Keep it in your logs: if you contact support, it is the value that lets us find your request. Treat it as an opaque string. You can also send your own X-Request-Id (letters, digits and ._:;=+/@-, at most 256 characters), and the API will use it. See Environments.
Step 4: Get your organization ids
Most routes act on one organization and take its id in the path: /v1/{organizationId}/.... That id is the organization's _id from GET /v1/organization. It is never the slug, so /v1/stem/exam does not work.
curl "$MTO_API_BASE/organization" \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Organizations fetched successfully.",
"data": [
{
"_id": "64b7f0c2a1e4d5f6a7b8c9d1",
"name": "STEM Olympiad",
"slug": "stem",
"logo": "https://example.org/logos/stem.png",
"desc": "International STEM olympiad.",
"defaultRedirect": "https://my.example-stem.org"
},
{
"_id": "64b7f0c2a1e4d5f6a7b8c9d2",
"name": "Hi-Lingua",
"slug": "hilingua",
"logo": "https://example.org/logos/hilingua.png",
"desc": "International language olympiad.",
"defaultRedirect": "https://my.example-hilingua.org"
}
],
"pagination": { "page": 1, "limit": 20, "total": 5, "totalPages": 1 }
}
(Example data, shortened to two of the five organizations. mto, the core record, is not listed:
the routes without an organization id act on it.)
Build a slug-to-id map once and keep it. Organizations rarely change, so caching the map for a day is fine. The list is not filtered by your roles, so it can include organizations you cannot act on; use the ones you agreed with the operator.
const res = await fetch(`${process.env.MTO_API_BASE}/organization?limit=100`, {
headers: { Authorization: `Bearer ${token}` },
});
const { data } = await res.json();
const orgIds = Object.fromEntries(data.map((o) => [o.slug, o._id]));
// orgIds.stem === '64b7f0c2a1e4d5f6a7b8c9d1'
$ch = curl_init(getenv('MTO_API_BASE') . '/organization?limit=100');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer $token"],
]);
$body = json_decode(curl_exec($ch), true);
$orgIds = array_column($body['data'], '_id', 'slug');
// $orgIds['stem'] === '64b7f0c2a1e4d5f6a7b8c9d1'
Organizations explains which data lives on the core record (mto) and which lives in each organization, and why most routes need this id.
A mistake worth seeing once
Using the slug where the id belongs is the most common first mistake. The API answers it like an organization that does not exist:
curl "$MTO_API_BASE/stem/exam" -H "Authorization: Bearer $TOKEN"
{
"error": {
"code": "not_found",
"message": "Organization not found!",
"documentation_url": "https://hub.main-team.org/api/errors#not_found",
"request_id": "8c1d2e3f-4a5b-4c6d-8e7f-9a0b1c2d3e4f"
}
}
Put 64b7f0c2a1e4d5f6a7b8c9d1 (the _id) where stem is and the same request reaches the stem organization.
If your first call fails
Every error has the same shape: { "error": { "code", "message", "documentation_url", "request_id" } }. Base your code on the HTTP status and error.code, not on the message text.
Status 401: unauthorized
{
"error": {
"code": "unauthorized",
"message": "Authentication is required or the provided credentials are invalid.",
"documentation_url": "https://hub.main-team.org/api/errors#unauthorized",
"request_id": "0b5c6d0e-8f7a-4b1c-9d2e-3f4a5b6c7d8e"
}
}
Every authentication failure gets this exact response, whatever the cause. That is deliberate: someone holding a stolen key or token learns nothing from it. So work through the list yourself:
- The header is exactly
Authorization: Bearer <token>: capitalB, one space, no quotes. - The token's header has
"alg": "HS256"and"kid"equal to yourapiKey, character for character. - The payload has
"sub"equal to the sameapiKey. iatandexpare both present and are whole seconds. Milliseconds (Date.now()in JavaScript) are the classic mistake.expis later thaniat, and at most 3600 seconds later.- Your server clock is within 30 seconds of real time.
- You signed with the full secret,
secret_prefix included, with no trailing newline. - The token has not expired and was not revoked, and your account has not been deactivated.
Authentication goes through each item in detail, with a way to decode your token locally.
Status 403: forbidden
{
"error": {
"code": "forbidden",
"message": "Insufficient role permissions",
"documentation_url": "https://hub.main-team.org/api/errors#forbidden",
"request_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9"
}
}
Your token is fine, but your account has no role for this route. On this page that means one of these is missing:
| Call | Needs | On |
|---|---|---|
GET /v1/api-account/validate-me | api/* (only api/*, */* or * grant it; */read does not) | mto or * |
GET /v1/organization | organization/read (also granted by */read, organization/*, */* or *) | mto or * |
A new token or a retry does not help. Ask the operator for the role, and see Permissions for what to ask for.
Other answers
| Status | Code | What it means here |
|---|---|---|
| 404 | not_found | Organization not found!: the path has a slug or an unknown id where the organization _id belongs |
| 429 | too_many_requests | You sent more than 100 requests to one route within 60 seconds. Wait the number of seconds in the Retry-After header. See Rate limits |
| 5xx | internal_error | A problem on our side. Retry later, and quote the request_id if it persists |
Next steps
- Build token handling properly: Authentication and the token handling tutorial.
- Learn where data lives: Organizations.
- Check what your roles allow: Permissions.
- Register a student and enter them for an exam, end to end: Register and apply.
- Prefer a ready-made client? See Client libraries.