Guides
Your API account
Two routes act on the API account your token belongs to:
| Route | What it does | Operation |
|---|---|---|
GET /v1/api-account/validate-me | Returns the account the token belongs to, with its roles. | Get the current API account |
POST /v1/api-account/revoke-token | Revokes 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 have | validate-me and revoke-token answer |
|---|---|
A valid token and the api/* permission | 200 |
A valid token, but no role that grants api/* | 403, code forbidden, Insufficient role permissions |
| No token, or a token that fails any check | 401, 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
| Field | Type | Meaning |
|---|---|---|
_id | string | Your account's id. Students you register belong to this id. |
apiKey | string | Your public key, the value you put in the token's kid header and sub claim: key_ followed by 24 characters. |
companyName | string | The name the operator gave your account. |
scopes | array of strings | Informational. What you may do is decided by roles, not by scopes. |
roles | array of objects | Your permissions. Each role has effect (allow or disallow), action (for example student/read), target (* or an organization slug) and, when set, authorized. |
isActive | boolean | Always 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":
| Result | What it means | What to do |
|---|---|---|
200 and the roles you expect | Token, clock and account are all fine. | Carry on. |
200, but a role you need is missing | Authentication works; a later call will get 403. | Ask the operator for the permission. |
401 | The 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. |
403 | The token is fine; the account lacks api/*. | Ask for api/*, or check your token with a route you do have. |
5xx or a timeout | A 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
| Situation | What to do |
|---|---|
| A process that holds a long-lived token shuts down | Revoke 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 system | Revoke that token, then sign a new one. |
| Your apiSecret was exposed | Revoking 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
| Status | Code | Message | When |
|---|---|---|---|
401 | unauthorized | Authentication 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. |
403 | forbidden | Insufficient role permissions | Your account lacks api/*. Without it you cannot revoke your own tokens; keep token lifetimes short, and ask the operator for the permission. |
400 | bad_request | Invalid 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.
Related
- Authentication: signing tokens, the lifetime and clock rules, and the
401checklist. - Token handling: caching a token, several servers, and revoking on shutdown.
- Permissions: what each role grants.