Skip to content
API documentation
View as MarkdownOpen in Claude

Guides

Your API account

Two routes act on the API account your token belongs to:

RouteWhat it doesOperation
GET /v1/api-account/validate-meReturns the account the token belongs to, with its roles.Get the current API account
POST /v1/api-account/revoke-tokenRevokes the token you send it with, and only that token.Revoke a token

Neither route changes your account itself. There is no route to create an account, change its roles, rotate its secret or deactivate it. An operator does all of that, and you can ask for it at info@main-team.org. How to get credentials and sign a token is on Authentication.

Permission

Both routes need the action api/* on the core record. The action is written with a wildcard, so only a role whose action is api/*, */* or * grants it, with target mto or *. A role such as student/* does not.

You havevalidate-me and revoke-token answer
A valid token and the api/* permission200
A valid token, but no role that grants api/*403, code forbidden, Insufficient role permissions
No token, or a token that fails any check401, code unauthorized

Note

A 403 here still tells you something useful: permissions are checked only after a token has been accepted, so a 403 means your token is valid and your account only lacks this one permission.

Check your token and account

GET /v1/api-account/validate-me is the simplest authenticated call there is. It reads nothing but your token, so it is the right first call for a new integration and the right health check for a running one.

curl "https://api.main-team.org/v1/api-account/validate-me" \
  -H "Authorization: Bearer $TOKEN"
{
  "_id": "66f1c2a9e4b0a1d2c3f4a5b6",
  "apiKey": "key_Q2x5cGhvbnktZXhhbXBsZS1r",
  "companyName": "Northwind Learning",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "api/*", "target": "mto" },
    { "effect": "allow", "action": "*/read", "target": "*" },
    { "effect": "allow", "action": "student/*", "target": "mto" },
    { "effect": "allow", "action": "student/update", "target": "*" },
    { "effect": "allow", "action": "auth/signin", "target": "*" },
    { "effect": "allow", "action": "application/*", "target": "*" },
    { "effect": "disallow", "action": "application/delete", "target": "*" }
  ],
  "isActive": true
}

Warning

This is the one response without the envelope. Every other success response is { "success", "message", "data", "pagination"? }. validate-me returns the account object itself, so read apiKey from the top level of the body, not from data. Client code that unwraps data for every call needs an exception for this route.

Fields

FieldTypeMeaning
_idstringYour account's id. Students you register belong to this id.
apiKeystringYour public key, the value you put in the token's kid header and sub claim: key_ followed by 24 characters.
companyNamestringThe name the operator gave your account.
scopesarray of stringsInformational. What you may do is decided by roles, not by scopes.
rolesarray of objectsYour permissions. Each role has effect (allow or disallow), action (for example student/read), target (* or an organization slug) and, when set, authorized.
isActivebooleanAlways true in a successful response: a deactivated account cannot authenticate at all.

Your apiSecret is never returned, by this or by any other route. The platform cannot show it to you again, so if you lose it, ask for a new account.

Permissions explains how to read roles: how an action with wildcards matches, what a target of * or mto covers, and why a matching disallow always wins.

Use it as a health check

Call validate-me when your integration starts, and whenever you want to know whether "the API is down" or "our credentials are wrong":

ResultWhat it meansWhat to do
200 and the roles you expectToken, clock and account are all fine.Carry on.
200, but a role you need is missingAuthentication works; a later call will get 403.Ask the operator for the permission.
401The token was refused: wrong key or secret, a bad claim, a clock too far off, an expired or revoked token, or a deactivated account. The reason is never given.Work through the checklist on Authentication. Quote the request_id if you contact support.
403The token is fine; the account lacks api/*.Ask for api/*, or check your token with a route you do have.
5xx or a timeoutA problem on the platform's side.Retry with backoff (Retries and idempotency).

Changes an operator makes to your account take up to 60 seconds to reach the API. That covers new or removed roles, and deactivation. Right after an operator changes your roles, validate-me can still show the old ones for up to a minute.

import jwt from 'jsonwebtoken';

const BASE = 'https://api.main-team.org/v1';
const API_KEY = process.env.MTO_API_KEY;
const API_SECRET = process.env.MTO_API_SECRET;

function mintToken(lifetimeSeconds = 900) {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign({ sub: API_KEY, iat: now, exp: now + lifetimeSeconds }, API_SECRET, {
    algorithm: 'HS256',
    keyid: API_KEY,
  });
}

export async function checkCredentials(requiredActions = []) {
  const res = await fetch(`${BASE}/api-account/validate-me`, {
    headers: { Authorization: `Bearer ${mintToken(60)}` },
  });
  if (res.status === 401) throw new Error('Token refused: check key, secret, clock and account status');
  if (res.status === 403) throw new Error('Token valid, but the account lacks api/*');
  if (!res.ok) throw new Error(`validate-me: HTTP ${res.status}`);

  const account = await res.json(); // no envelope on this route
  const allowed = new Set(
    account.roles.filter((r) => r.effect === 'allow').map((r) => r.action),
  );
  const missing = requiredActions.filter((a) => !allowed.has(a));
  return { account, missing }; // a plain string match; see /api/permissions for wildcards
}
<?php
use Firebase\JWT\JWT; // composer require firebase/php-jwt

const MT_BASE = 'https://api.main-team.org/v1';

function mt_mint_token(int $lifetimeSeconds = 900): string
{
    $apiKey = getenv('MTO_API_KEY');
    $now = time();
    $payload = ['sub' => $apiKey, 'iat' => $now, 'exp' => $now + $lifetimeSeconds];
    return JWT::encode($payload, getenv('MTO_API_SECRET'), 'HS256', $apiKey);
}

function mt_check_credentials(): array
{
    $ch = curl_init(MT_BASE . '/api-account/validate-me');
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . mt_mint_token(60)],
        CURLOPT_TIMEOUT => 30,
    ]);
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status === 401) throw new RuntimeException('Token refused: check key, secret, clock and account status');
    if ($status === 403) throw new RuntimeException('Token valid, but the account lacks api/*');
    if ($status !== 200) throw new RuntimeException("validate-me: HTTP $status");

    return json_decode($raw, true); // the account itself, not an envelope
}

The check in the Node.js example compares action names as plain strings, which is enough to spot a missing role such as auth/signin. It does not expand wildcards: a role of */* grants auth/signin but would be reported as missing. Use the matching rules on Permissions if you want an exact answer.

Revoke a token

POST /v1/api-account/revoke-token revokes the token you call it with. From then on every request carrying that token gets 401, on every server the API runs on. Nothing else changes: your apiSecret stays the same, other tokens you have signed keep working, and you can sign a new one straight away.

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

No body is needed, and you should send none. The token to revoke is the one in the Authorization header.

expiresIn is the number of seconds the revocation is held: the token's remaining lifetime plus 30 seconds. The extra 30 seconds match the clock tolerance the API allows after exp, so a revoked token cannot become valid again in that window. After expiresIn seconds the token would be refused as expired anyway. The value is at least 1 and at most 3660 (the one-hour maximum lifetime plus 60 seconds).

When to revoke

SituationWhat to do
A process that holds a long-lived token shuts downRevoke the token as part of shutdown, so a copy left in memory dumps or logs is useless.
One token was exposed: it was logged, pasted into a ticket, or sent to the wrong systemRevoke that token, then sign a new one.
Your apiSecret was exposedRevoking tokens is not enough, because whoever holds the secret can sign new ones. Ask the operator to deactivate the account at info@main-team.org. Deactivation stops every token signed with that secret within 60 seconds.

Security

A replacement account starts empty. Students belong to the account that registered them, so a new account does not see the students the old one registered. Agree the move with the operator before a compromised account is replaced. Security covers secret storage and incident handling in more depth.

A replacement token must differ from the revoked one

A token is revoked by its exact text. Signing is deterministic, so a token signed with the same key, the same iat and the same exp is the same text, and it is refused as revoked too. This bites a client that revokes a token and immediately signs a replacement within the same second, with the same lifetime. Make sure the new token's iat or exp differs: wait a second, or change the lifetime by a second.

Refusals

StatusCodeMessageWhen
401unauthorizedAuthentication is required or the provided credentials are invalid.The token was not accepted, including a token that is already revoked. There is nothing to revoke.
403forbiddenInsufficient role permissionsYour account lacks api/*. Without it you cannot revoke your own tokens; keep token lifetimes short, and ask the operator for the permission.
400bad_requestInvalid token format. or No token provided.The token could not be read or has no exp. A token the API has accepted always has one, so you should never see this. Nothing was revoked.

Revoking twice is harmless in effect, but the second call gets 401: the token is already revoked, so it no longer authenticates the call that would revoke it.

export async function revoke(token) {
  const res = await fetch(`${BASE}/api-account/revoke-token`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
  });
  if (res.status === 401) return { alreadyUnusable: true };
  if (!res.ok) throw new Error(`revoke-token: HTTP ${res.status}`);
  const { data } = await res.json();
  return { expiresIn: data.expiresIn };
}

// On shutdown:
// process.on('SIGTERM', async () => { await revoke(currentToken); process.exit(0); });
<?php
function mt_revoke(string $token): ?int
{
    $ch = curl_init(MT_BASE . '/api-account/revoke-token');
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
        CURLOPT_TIMEOUT => 30,
    ]);
    $raw = curl_exec($ch);
    $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($status === 401) return null; // already unusable
    if ($status !== 200) throw new RuntimeException("revoke-token: HTTP $status");
    return json_decode($raw, true)['data']['expiresIn'];
}

Rate limits

Both routes count against your rate limit like any other: 100 requests per 60 seconds, per account and per operation. A health check every few seconds from several servers adds up, and they all share one budget because the count belongs to the account. Once a minute is plenty. See Rate limits.

Search the API documentation

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