REST API
Authentication
Exchange an App Key for a token with POST /token, and learn what a token may do with GET /me.
POST /token#
Exchanges an App Key for a token. This is the only route that takes the key itself, and the only one without a token.
POST /api/{domain}/token
Content-Type: application/json
{ "key": "<80-character App Key>" }| Field | Meaning |
|---|---|
key | The App Key. |
ttl_seconds | Optional. Shortens the token's life, 1 to 86400: it lives the shorter of this and the App Key's token TTL. It changes nothing else (below). |
scope | Optional. Narrows what the token may do (Browser tokens). |
Response 200:
{
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"token_type": "Bearer",
"expires_in": 30
}
}| Field | Meaning |
|---|---|
token | Send as Authorization: Bearer <token> or ?token=. |
token_type | Always Bearer. |
expires_in | Seconds until it expires: the App Key's token TTL, or shorter (ttl_seconds, a scope's). |
Errors: 401 Invalid App Key (wrong format, unknown key, or a key of another Domain); 404
unknown Domain; 403 inactive Domain; 422 on ttl_seconds out of range, or an invalid scope. The
key is checked first: a bad key is 401 whatever else is wrong.
curl -s -X POST https://api.thuum.dev/api/acme/token \
-H 'content-type: application/json' \
-d "{\"key\":\"$THUUM_APP_KEY\"}"const response = await fetch('https://api.thuum.dev/api/acme/token', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: process.env.THUUM_APP_KEY })
});
if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
const { data } = await response.json();const { token, expires_in } = await thuum.exchangeToken('acme', process.env.THUUM_APP_KEY!);import os, requests
response = requests.post(
"https://api.thuum.dev/api/acme/token",
json={"key": os.environ["THUUM_APP_KEY"]},
timeout=10,
)
response.raise_for_status()
token = response.json()["data"]["token"]Reusing tokens on a server#
A server making many REST calls can reuse a token until shortly before it expires:
let cached = null;
export async function thuumToken() {
if (cached && cached.expiresAt - 5_000 > Date.now()) return cached.token;
const response = await fetch(`${process.env.THUUM_API_URL}/api/acme/token`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: process.env.THUUM_APP_KEY })
});
if (!response.ok) throw new Error(`Token exchange failed: ${response.status}`);
const { data } = await response.json();
cached = { token: data.token, expiresAt: Date.now() + data.expires_in * 1000 };
return cached.token;
}If a request still answers 401, drop the cached token, get a new one and retry once.
Shorter tokens#
ttl_seconds gives a token a shorter life than the App Key's TTL, with the same rights:
unlike a scope, it narrows nothing. A token can only get shorter this way, never longer.
Use it for a token that only needs to live a moment, such as one handed to a browser just to open a WebSocket. The WebSocket checks the token when it connects; the connection then stays open after the token expires. To also limit what the browser may do with it, use a scope as well: the token then lives the shortest of the three.
curl -s -X POST https://api.thuum.dev/api/acme/token \
-H 'content-type: application/json' \
-d "{\"key\":\"$THUUM_APP_KEY\",\"ttl_seconds\":60}"const { token } = await thuum.exchangeToken('acme', process.env.THUUM_APP_KEY!, {
ttl_seconds: 60
});What's in a token#
A token is a JWT signed by Thuum. Its payload holds the App Key's selector (sub), the issuer
(iss, thuum.dev), and the issue and expiry times (iat, exp). It holds no permissions: those
are looked up from the key's claims. You can read the payload for display (the client's
decodeToken) but only Thuum can verify it.
GET /me#
Describes the caller: its Domain, App Key, token expiry and linked claims. Any valid token may call it; it needs no claim. Use it to show only the features a key may use, or to check a key's setup in a test.
curl -s https://api.thuum.dev/api/acme/me -H "authorization: Bearer $TOKEN"{
"data": {
"domain": { "slug": "acme", "name": "Acme Inc.", "description": null },
"key": {
"selector": "3f9c1a7be04d52aa",
"name": "web-browser",
"token_ttl_seconds": 60,
"inserted_at": "2026-10-20T09:12:44.000000Z"
},
"token": { "expires_at": "2026-10-21T14:03:12Z" },
"domain_claim": null,
"event_claim": {
"id": "8b1d…",
"name": "Browser chat",
"description": null,
"topic_flags": 0,
"fallback_data_flags": 0,
"topics": { "chat": 11 }
},
"state_claim": {
"id": "c40e…",
"name": "Browser presence",
"description": null,
"topic_flags": 0,
"fallback_data_flags": 0,
"topics": { "presence": 11 }
}
}
}A claim is null when the key isn't linked to one of that kind. domain_claim lists its protocols
as { "domain": 5, "event": 1 }.
import { resolveDataFlags, has, PUBLISH } from '@thuum/client';
const me = await acme.me();
const canPost =
me.event_claim !== null && has(resolveDataFlags(me.event_claim, 'chat:lobby').flags, PUBLISH);