Skip to content
API documentation
View as MarkdownOpen in Claude

Start here

Authentication

Every route except GET /v1/health needs a token. There is no login endpoint and no OAuth flow: you sign your own token with the secret you were issued, and the API checks the signature. This page covers everything about that token: what it contains, how to sign it in Node.js, PHP and bash, how to reuse it, how to revoke it, and what to check when the API answers 401.

Your credentials

An operator creates your account and gives you two values, once.

apiKeyapiSecret
What it isYour account's public identifierYour signing key
Formatkey_ followed by exactly 24 characters (A-Z, a-z, 0-9, -, _), 28 characters in totalsecret_ followed by a long random string
Where it goesIn every token, as the kid header and the sub claimNowhere. It never leaves your server. You use it only to compute signatures
Secret?No. It identifies you; on its own it grants nothingYes. Anyone holding it can act as your account
Can you get it again?Ask the operatorNo. It is shown once and cannot be recovered
Can it be changed?NoNo. There is no rotation on an existing account

The key and the secret are independent random values. You cannot derive one from the other.

Security

Keep the apiSecret in a secret manager or in your server's environment. Never commit it, log it, send it by email or chat, put it in a browser or mobile app, or paste it into a website (online JWT debuggers included). If you think it has leaked, follow If your secret leaks at once.

How a request is authenticated

You send the token in the Authorization header:

GET /v1/api-account/validate-me HTTP/1.1
Host: api.main-team.org
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleV83ZlF4MkxtTjlwUnRWdzNZekExYkM0ZEUifQ.eyJzdWIiOiJrZXlfN2ZReDJMbU45cFJ0VnczWXpBMWJDNGRFIiwiaWF0IjoxNzg5NDgxNjAwLCJleHAiOjE3ODk0ODUyMDB9.3nQ0k1Q9...

The API then:

  1. reads kid from the token header and finds the active account with that apiKey,
  2. checks the signature with that account's secret, accepting HS256 only,
  3. checks the timestamps and that sub equals kid,
  4. checks that the token has not been revoked.

If every check passes, the request continues to the permission check (see Permissions). If any check fails, the answer is the same 401, described in The 401 checklist.

Token anatomy

A token is a standard JSON Web Token (JWT): three base64url-encoded parts joined by dots, header.payload.signature.

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "key_7fQx2LmN9pRtVw3YzA1bC4dE"
}
FieldRequiredRule
algYesMust be HS256 (HMAC with SHA-256). Every other algorithm is refused, including none, HS384, HS512 and RS256.
kidYesYour apiKey, exactly as issued. This is how the API knows which account to check the signature against.
typNoJWT is conventional, and most libraries add it.

Payload (claims)

{
  "sub": "key_7fQx2LmN9pRtVw3YzA1bC4dE",
  "iat": 1789481600,
  "exp": 1789485200
}
ClaimRequiredRule
subYesYour apiKey, the same value as kid. The signed subject has to match the key the token claims to be for.
iatYesIssued-at time, in whole seconds since the Unix epoch (UTC). It may be at most 30 seconds ahead of the API's clock.
expYesExpiry time, in whole seconds since the Unix epoch. It must be later than iat and at most 3600 seconds (one hour) after it.
nbfNoOptional "not before". If present, it is honored, with the same 30 seconds of tolerance.

Any other claims are ignored. Do not put anything sensitive in the payload: it is only base64url-encoded, and anyone who sees the token can read it.

Signature

The signature is HMAC-SHA256 over base64url(header) + "." + base64url(payload), with your apiSecret as the key.

Use the secret as text, exactly as issued: the whole string, secret_ prefix included, encoded as UTF-8. Do not base64-decode it, trim parts of it, or add a newline to it. JWT libraries do this correctly when you pass the secret as a plain string.

Lifetime rules at a glance

RuleValue
Longest lifetime (exp − iat)3600 seconds
Shortest lifetimeexp must be later than iat
Clock tolerance after exp30 seconds
How far iat may be in the future30 seconds
UnitsSeconds, never milliseconds

Why these rules exist. A token is a bearer credential: whoever holds it can use it until it expires. Requiring exp, and capping the lifetime at one hour, limits what a leaked token is worth. The cap on a future iat stops anyone from minting a token "for next week" that would stay valid until then. The 30 seconds of tolerance absorb small clock differences between your servers and ours.

Sign a token

Pick the language you use. Each example produces a token that is valid for 3600 seconds, the maximum.

Node.js with jsonwebtoken

npm install jsonwebtoken
// token.js
const jwt = require('jsonwebtoken');

const API_KEY = process.env.MTO_API_KEY;       // key_...
const API_SECRET = process.env.MTO_API_SECRET; // secret_... (the full value)
const LIFETIME_SECONDS = 3600;                 // the maximum the API accepts

function mintToken() {
  const iat = Math.floor(Date.now() / 1000); // seconds, not milliseconds
  return jwt.sign(
    { sub: API_KEY, iat, exp: iat + LIFETIME_SECONDS },
    API_SECRET,
    {
      algorithm: 'HS256',
      keyid: API_KEY, // written to the header as "kid"
    },
  );
}

module.exports = { mintToken };

Notes for jsonwebtoken:

  • Put iat and exp in the payload or use the expiresIn option, not both. The library refuses a payload exp combined with expiresIn.
  • The library adds iat automatically unless you pass noTimestamp: true. Never pass noTimestamp: the API requires iat.
  • With another Node library, such as jose, check that it sets iat. In jose that means calling .setIssuedAt().

PHP with firebase/php-jwt

composer require firebase/php-jwt
<?php
// Token.php
require __DIR__ . '/vendor/autoload.php';

use Firebase\JWT\JWT;

function mintToken(string $apiKey, string $apiSecret, int $lifetime = 3600): string
{
    $iat = time(); // seconds
    return JWT::encode(
        ['sub' => $apiKey, 'iat' => $iat, 'exp' => $iat + $lifetime],
        $apiSecret,
        'HS256',
        $apiKey // $keyId: written to the header as "kid"
    );
}

$token = mintToken(getenv('MTO_API_KEY'), getenv('MTO_API_SECRET'));

JWT::encode($payload, $key, $alg, $keyId) sets kid from its fourth argument. If you leave it out, the token has no kid and the API refuses it.

bash with openssl

Useful for a quick test from a terminal, or in a shell script where you cannot install a library.

#!/usr/bin/env bash
# mint-token.sh: prints a token for MTO_API_KEY / MTO_API_SECRET
set -euo pipefail
: "${MTO_API_KEY:?set MTO_API_KEY}"
: "${MTO_API_SECRET:?set MTO_API_SECRET}"

lifetime=${1:-3600}

# base64url without padding, as JWT requires
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 + lifetime))" | b64url)
signature=$(printf '%s.%s' "$header" "$payload" \
  | openssl dgst -sha256 -hmac "$MTO_API_SECRET" -binary | b64url)

printf '%s.%s.%s\n' "$header" "$payload" "$signature"
export TOKEN=$(./mint-token.sh)
curl -s https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"

Warning

openssl dgst -hmac takes the secret as a command-line argument, and on a shared machine other users can see command lines in the process list. Use this script on your own workstation or a single-tenant server, and use a JWT library in production code.

Any other language

Any JWT library that supports HS256 and lets you set the kid header works. There are no key pairs, certificates or PEM files. Check three things in your library: it signs with HS256, it writes kid into the header (not the payload), and it writes iat and exp in seconds. Build your own client lists everything else a client needs.

Reuse tokens: the cache pattern

You can sign a new token for every request; signing is cheap. But reusing one token until shortly before it expires is simpler to debug, and it means one fewer thing can go wrong on every call. The pattern:

  1. Keep the current token and its exp in memory.
  2. Before each request, if the token expires within the next 60 seconds, sign a new one.
  3. Otherwise reuse it.

A 60-second margin makes sure a token never expires while a request is in flight, and absorbs small clock differences.

Node.js

// token-cache.js
const jwt = require('jsonwebtoken');

const API_KEY = process.env.MTO_API_KEY;
const API_SECRET = process.env.MTO_API_SECRET;
const LIFETIME_SECONDS = 3600;
const REFRESH_MARGIN_SECONDS = 60;

let current = null; // { token, exp }

function getToken() {
  const now = Math.floor(Date.now() / 1000);
  if (current && current.exp - REFRESH_MARGIN_SECONDS > now) {
    return current.token;
  }
  const iat = now;
  const exp = iat + LIFETIME_SECONDS;
  const token = jwt.sign({ sub: API_KEY, iat, exp }, API_SECRET, {
    algorithm: 'HS256',
    keyid: API_KEY,
  });
  current = { token, exp };
  return token;
}

async function api(path, { method = 'GET', body } = {}) {
  const res = await fetch(`https://api.main-team.org/v1${path}`, {
    method,
    headers: {
      Authorization: `Bearer ${getToken()}`,
      ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
    },
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const requestId = res.headers.get('x-request-id');
  // Error bodies are JSON too, but a proxy in between can answer with HTML.
  const text = await res.text();
  let payload = null;
  try {
    payload = text ? JSON.parse(text) : null;
  } catch {
    payload = null;
  }
  if (!res.ok) {
    const err = new Error(`${res.status} ${payload?.error?.code}: ${payload?.error?.message}`);
    err.status = res.status;
    err.code = payload?.error?.code;
    err.requestId = payload?.error?.request_id ?? requestId;
    throw err;
  }
  return payload;
}

module.exports = { getToken, api };

PHP

A PHP-FPM worker does not keep variables between requests, so an in-memory cache only helps inside one long-running script such as a queue worker or a cron job. For web requests you have two good options: sign a fresh token per request, or keep the token in a shared cache such as APCu. The class below does the second when APCu is available and falls back to the first.

<?php
use Firebase\JWT\JWT;

final class MtoToken
{
    private const LIFETIME = 3600;
    private const MARGIN = 60;
    private const CACHE_KEY = 'mto_api_token';

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

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

    public function get(): string
    {
        $now = time();

        if ($this->token !== null && $this->exp - self::MARGIN > $now) {
            return $this->token;
        }

        if (function_exists('apcu_fetch')) {
            $cached = apcu_fetch(self::CACHE_KEY);
            if (is_array($cached) && $cached['exp'] - self::MARGIN > $now) {
                [$this->token, $this->exp] = [$cached['token'], $cached['exp']];
                return $this->token;
            }
        }

        $iat = $now;
        $this->exp = $iat + self::LIFETIME;
        $this->token = JWT::encode(
            ['sub' => $this->apiKey, 'iat' => $iat, 'exp' => $this->exp],
            $this->apiSecret,
            'HS256',
            $this->apiKey
        );

        if (function_exists('apcu_store')) {
            // Only the token is cached, never the secret.
            apcu_store(self::CACHE_KEY, ['token' => $this->token, 'exp' => $this->exp],
                $this->exp - $now - self::MARGIN);
        }

        return $this->token;
    }
}

$tokens = new MtoToken(getenv('MTO_API_KEY'), getenv('MTO_API_SECRET'));
$ch = curl_init('https://api.main-team.org/v1/api-account/validate-me');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tokens->get()],
]);
$account = json_decode(curl_exec($ch), true);

Several servers

Each of your servers or workers can sign its own tokens with the same key and secret. There is no limit on how many valid tokens an account has at one time, and nothing needs to be shared between machines. The rate limit belongs to the account, so it is shared however many servers you run (see Rate limits).

Clocks

Your token's iat and exp come from your server's clock and are checked against ours:

If your clock isEffect
Within 30 seconds of real timeWorks
More than 30 seconds aheadEvery new token is refused, because iat is in the future
BehindTokens run out sooner than you expect, and a token looks expired as soon as your clock is more than an hour and 30 seconds behind

Run NTP on every machine that signs tokens. A 401 that starts suddenly on one server but not on others usually means a clock problem.

The token handling tutorial covers these patterns in more depth, including revoking on shutdown.

Check a token: validate-me

GET /v1/api-account/validate-me returns the account the token belongs to. Use it to check a new setup, to check your integration from monitoring, and to read your current roles.

curl -s https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"
{
  "_id": "66f1a2b3c4d5e6f7a8b9c0d1",
  "apiKey": "key_7fQx2LmN9pRtVw3YzA1bC4dE",
  "companyName": "Northwind Learning Ltd",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "api/*", "target": "mto" },
    { "effect": "allow", "action": "*/read", "target": "*" },
    { "effect": "allow", "action": "student/*", "target": "mto" }
  ],
  "isActive": true
}
FieldMeaning
_idYour account's id. The students you register belong to this id. A role whose authorized is set must name this id (see Permissions).
apiKeyYour key, the same as the token's kid.
companyNameThe name the operator gave your account.
scopesInformational labels set by the operator. They are not used for access decisions; roles are.
rolesYour permissions: a list of { effect, action, target, authorized? }. See Permissions.
isActiveAlways true here, because a deactivated account cannot authenticate at all.

The response never contains your apiSecret.

No envelope. Unlike every other JSON route, validate-me returns the account object on its own, not inside { "success", "message", "data" }. If your client unwraps data automatically, make an exception for this route.

Permission. validate-me needs the action api/* on mto. Only a role action of api/*, */* or * grants it. A token that is fine but lacks this role gets 403 forbidden, not 401. That difference is useful: a 403 here proves your token is valid.

Reference: getCurrentApiAccount. More uses: API account guide.

Revoke a token: revoke-token

POST /v1/api-account/revoke-token revokes the token you send it with. From then on, that token answers 401 on every request. Nothing else changes: your account, your secret and your other tokens keep working, and you can sign a new token at once.

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

expiresIn is how many seconds the revocation is kept: the token's remaining lifetime plus the 30 seconds of clock tolerance, at least 1 and at most 3660. After that, the token is refused because it has expired, so no revocation needs to be kept.

The request has no body. The only token it can revoke is the one in its own Authorization header, so to revoke a token you must still hold it.

AnswerWhen
200The token is revoked
401 unauthorizedThe token was already invalid: expired, revoked, or otherwise refused. There is nothing left to revoke
403 forbiddenYour account lacks api/* on mto, the permission this route needs, like validate-me
400 bad_requestNo token provided. or Invalid token format.: the token could not be read or carries no exp. A token that passed authentication always has one, so you should not see this

When to revoke:

  • On shutdown of a long-running worker, so that a token left in memory or in a crash dump is worthless.
  • When a token may have leaked, for example if it was written to a log or pasted into a ticket.
  • When you rotate to a new token early for any reason.

Note

HS256 signing is deterministic. Two tokens with the same header and the same claims are the same string, so if two of your workers sign { sub, iat, exp } in the same second, they hold one token, and revoking it cuts off both. If your workers revoke on shutdown, give each token something unique, for example a random jti claim. The API ignores jti, but it makes every token distinct.

Revoking a token does not protect you from a leaked secret: whoever holds the secret can sign new tokens. For that, see Changes to your account and If your secret leaks.

Reference: revokeToken.

Changes to your account

Operators manage accounts; there is no route to change your own. What you can expect:

ChangeWho makes itWhen it takes effect
Your roles are changedOperatorWithin 60 seconds. Roles belong to your account, not to the token, so tokens you already hold pick up the new roles; you do not need to sign new ones
Your account is deactivatedOperatorWithin 60 seconds, every token of the account answers 401
A single token is revokedYou, with revoke-tokenAt once
Your secret is changedNot possibleA new secret means a new account

If your secret leaks

  1. Email info@main-team.org from the address you normally use with us. Ask for the account to be deactivated, and give its apiKey (never the secret). Within 60 seconds of deactivation, every token signed with the secret stops working.
  2. Revoke the tokens you hold, if you want to cut them off before the operator acts. This does not stop new tokens being signed with the leaked secret; only deactivation does.
  3. Find the cause and fix it (a committed .env file, a log line, a shared screen) before you get new credentials.
  4. Agree the replacement with the operator. A new account comes with a new key and secret. Students belong to the account that registered them, so a new account does not see the students the old account registered. Discuss with the operator how to handle them before you switch.

The 401 checklist

Every authentication failure answers exactly this:

{
  "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"
  }
}

The API never says which check failed. A distinct message would tell someone holding a stolen key whether it is live, or a stolen token whether it was revoked. Our logs do record the reason against the request_id, so if you are stuck after this checklist, send us the request_id (never the token).

Go through the list in order. It follows the order in which the API checks.

#CheckTypical mistake
1The header is Authorization: Bearer <token>bearer in lower case, two spaces, quotes around the token, a missing header after a proxy or redirect
2The token is three base64url parts separated by dots, and its header and payload are JSONA line break or whitespace inside the token; the token URL-encoded twice
3The header has kid, and it equals your apiKey exactly: key_ plus 24 characterskid in the payload instead of the header; the key trimmed or with a trailing newline; a key from another environment
4Your account is activeThe account was deactivated
5alg is HS256The library defaulted to another algorithm
6The signature uses your full apiSecret as textThe secret_ prefix dropped; a trailing newline from a file; the secret base64-decoded first; a secret from another account
7The token has not expired (30 seconds of grace after exp), and any nbf has passedA cached token used past its exp; the clock runs slow
8iat and exp are both present, as numbersnoTimestamp set; claims sent as strings
9iat and exp are in secondsDate.now() (milliseconds) used directly
10iat is not more than 30 seconds ahead of real timeThe server clock runs fast; no NTP
11exp is later than iat, by at most 3600 secondsA 24-hour token; expiresIn: '2h'
12sub equals kidsub set to a user id or your company name
13The token has not been revokedThe token was revoked at shutdown and then reused

Decode your token locally

Inspect your token on your own machine. Never paste it into a website.

node -e '
const [h, p] = process.argv[1].split(".");
console.log(JSON.parse(Buffer.from(h, "base64url")));
const claims = JSON.parse(Buffer.from(p, "base64url"));
const now = Math.floor(Date.now() / 1000);
console.log(claims);
console.log({ lifetime: claims.exp - claims.iat, iatVsNow: claims.iat - now, expiresIn: claims.exp - now });
' "$TOKEN"
{ alg: 'HS256', typ: 'JWT', kid: 'key_7fQx2LmN9pRtVw3YzA1bC4dE' }
{ sub: 'key_7fQx2LmN9pRtVw3YzA1bC4dE', iat: 1789481600, exp: 1789485200 }
{ lifetime: 3600, iatVsNow: -12, expiresIn: 3588 }

A good token shows alg: 'HS256', kid and sub both equal to your key, lifetime of 3600 or less, and iatVsNow of 30 or less. If all of that is right and you still get 401, the signature or the secret is the problem (check 6).

Is it 401 or 403?

StatusMeaningWhat helps
401 unauthorizedThe API does not accept the tokenFix the token, using the checklist above
403 forbiddenThe token is fine, but your account has no role for this routeAsk the operator for the role. A new token does not help. See Permissions

A request refused with 401, or with 403 Insufficient role permissions, does not count against your rate limit.

What not to do

  • Do not call the API from a browser or an app. The API sends no CORS headers, and the secret would be exposed. See Environments.
  • Do not share one token across environments or accounts. Each token is tied to one key.
  • Do not log tokens. Redact the Authorization header in your HTTP logs. A token is a credential for up to an hour.
  • Do not make tokens longer-lived than you need. One hour is the maximum; shorter is fine.

More on protecting your integration: Security.

Search the API documentation

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