# Your API account

> Check which account a token belongs to and what it may do, and revoke a single token before it expires.

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](https://hub.main-team.org/api/reference/get-current-api-account) |
| `POST /v1/api-account/revoke-token` | Revokes the token you send it with, and only that token. | [Revoke a token](https://hub.main-team.org/api/reference/revoke-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](mailto:info@main-team.org). How to get credentials and sign a token is on
[Authentication](https://hub.main-team.org/api/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`](https://hub.main-team.org/api/errors#forbidden), `Insufficient role permissions` |
| No token, or a token that fails any check | `401`, code [`unauthorized`](https://hub.main-team.org/api/errors#unauthorized) |

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.

```bash
curl "https://api.main-team.org/v1/api-account/validate-me" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "_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
}
```

**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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/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.

```js [Node.js]
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 [PHP]
<?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](https://hub.main-team.org/api/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.

```bash
curl -X POST "https://api.main-team.org/v1/api-account/revoke-token" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "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](mailto:info@main-team.org). Deactivation stops every token signed with that secret within 60 seconds. |

**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](https://hub.main-team.org/api/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`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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`](https://hub.main-team.org/api/errors#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.

```js [Node.js]
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 [PHP]
<?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](https://hub.main-team.org/api/rate-limits).

## Related

- [Authentication](https://hub.main-team.org/api/authentication): signing tokens, the lifetime and clock rules, and the `401`
  checklist.
- [Token handling](https://hub.main-team.org/api/tutorials/token-handling): caching a token, several servers, and revoking on
  shutdown.
- [Permissions](https://hub.main-team.org/api/permissions): what each role grants.
