Mint one access token and reuse it
Mint one token per key in each process at POST /v2/oauth/token, cache it, and reuse it until expires_at = now + expires_in - 300. The token manager you build in step 2 also handles the two failures you can hit with a cached token: a 401 on a /v2 request and a 429 from POST /v2/oauth/token. Build the token manager when you port a v1 client that calls POST /v1/auth before every request, or when you call /v2 more than a few times a day.
Prerequisites
$BRELLIUM_CLIENT_IDand$BRELLIUM_CLIENT_SECRETfrom API keys in the dashboard. The dashboard shows the secret only when you create the key. Rotate secret in the key's row menu issues a new one, also shown once.- Bun or Node.js 22.6 or later for the TypeScript token manager, or Python 3.10 or later with
requests(pip install requests) for the Python one. - Either a live key or a sandbox key. With a sandbox key, also set
BRELLIUM_AUDIENCE=https://api.brellium.dev/sandbox; steps 2 and 3 then return the sample question setProgress Note Compliance. A live key returns your organization's own question sets.
Steps
Mint a token once with curl
Send grant_type, client_id, client_secret and audience as a JSON body. audience is https://api.brellium.dev for a live key and https://api.brellium.dev/sandbox for a sandbox key. A token request for the other audience gets an access_denied body from the token endpoint. The samples read $BRELLIUM_AUDIENCE and default to live.
curl "https://api.brellium.com/v2/oauth/token" \
--request POST \
--header "Content-Type: application/json" \
--data "{
\"grant_type\": \"client_credentials\",
\"client_id\": \"$BRELLIUM_CLIENT_ID\",
\"client_secret\": \"$BRELLIUM_CLIENT_SECRET\",
\"audience\": \"${BRELLIUM_AUDIENCE:-https://api.brellium.dev}\"
}"
const res = await fetch("https://api.brellium.com/v2/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: process.env.BRELLIUM_CLIENT_ID,
client_secret: process.env.BRELLIUM_CLIENT_SECRET,
audience: process.env.BRELLIUM_AUDIENCE || "https://api.brellium.dev",
}),
});
if (!res.ok)
throw new Error(`token request failed: ${res.status} ${await res.text()}`);
console.log(await res.json());
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 86400,
"scope": "documents:read documents:write audits:read ..."
}
expires_in is the token's lifetime in seconds (about a day). Read it and scope from every response instead of hardcoding them, and treat access_token as opaque: never decode it for an expiry. The response headers RateLimit-Limit: 20, RateLimit-Remaining: 19 and RateLimit-Reset: 0 report the token endpoint's own budget per client_id. For step 3, copy access_token into your shell so it stays out of history: read -s BRELLIUM_ACCESS_TOKEN && export BRELLIUM_ACCESS_TOKEN.
Build the token manager
The token manager's get() returns the cached token until expires_at (now + expires_in - 300 seconds), then re-mints. Concurrent callers share that mint: in TypeScript they await the same promise, and in Python they wait on the same lock. brelliumFetch and brellium_request set the Authorization header. On a 401 they pass the rejected token to invalidate(), call get() for a new one and retry the request once.
// Keep credentials on the server; never in a browser or mobile app.
const BASE_URL = "https://api.brellium.com";
const AUDIENCE = process.env.BRELLIUM_AUDIENCE ?? "https://api.brellium.dev";
// Refresh five minutes early, so a request never carries a token that expires
// mid-call.
const EARLY_REFRESH_MS = 300_000;
type TokenResponse = {
access_token: string;
token_type: string;
expires_in: number;
scope?: string;
};
type CachedToken = { token: string; expiresAt: number };
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
/**
* Milliseconds to wait after a 429: Retry-After, else RateLimit-Reset, else one
* second.
*/
function retryAfterMs(res: Response): number {
const header =
res.headers.get("Retry-After") ?? res.headers.get("RateLimit-Reset");
const seconds = Number(header);
return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 1_000;
}
/**
* One instance per client_id. Share it across the process; never share a token
* between keys.
*/
export class TokenManager {
private readonly clientId: string;
private readonly clientSecret: string;
private cached: CachedToken | undefined;
private inFlight: Promise<string> | undefined;
constructor(clientId: string, clientSecret: string) {
this.clientId = clientId;
this.clientSecret = clientSecret;
}
/** The cached token, or one mint that every concurrent caller waits for. */
async get(): Promise<string> {
if (this.cached && Date.now() < this.cached.expiresAt) return this.cached.token;
if (!this.inFlight) {
this.inFlight = this.mint().finally(() => {
this.inFlight = undefined;
});
}
return this.inFlight;
}
/**
* Drop a token the API rejected, so the next get() mints. A newer token is left
* alone.
*/
invalidate(token: string): void {
if (this.cached?.token === token) this.cached = undefined;
}
private async mint(): Promise<string> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${BASE_URL}/v2/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "client_credentials",
client_id: this.clientId,
client_secret: this.clientSecret,
audience: AUDIENCE,
}),
});
if (res.status === 429 && attempt < 2) {
// The token endpoint's own limit: wait exactly what the header says.
await sleep(retryAfterMs(res));
continue;
}
if (!res.ok) {
// Log the status and request id only, never the body of a token request or
// response.
throw new Error(
`token request failed: ${res.status} (x-request-id ` +
`${res.headers.get("x-request-id")})`,
);
}
const body = (await res.json()) as TokenResponse;
this.cached = {
token: body.access_token,
expiresAt: Date.now() + body.expires_in * 1000 - EARLY_REFRESH_MS,
};
return body.access_token;
}
}
}
const managers = new Map<string, TokenManager>();
/**
* One cache per client_id: a token minted for one key is never sent on behalf of
* another.
*/
export function tokenManagerFor(
clientId: string,
clientSecret: string,
): TokenManager {
let manager = managers.get(clientId);
if (!manager) {
manager = new TokenManager(clientId, clientSecret);
managers.set(clientId, manager);
}
return manager;
}
/**
* Send an authenticated request. On 401, drop the token, mint once and retry once.
*/
export async function brelliumFetch(
tokens: TokenManager,
path: string,
init: RequestInit = {},
): Promise<Response> {
const send = async (token: string) => {
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${token}`);
return fetch(`${BASE_URL}${path}`, { ...init, headers });
};
let token = await tokens.get();
let res = await send(token);
if (res.status === 401) {
tokens.invalidate(token);
token = await tokens.get();
res = await send(token);
}
return res;
}
async function main() {
const clientId = process.env.BRELLIUM_CLIENT_ID;
const clientSecret = process.env.BRELLIUM_CLIENT_SECRET;
if (!clientId || !clientSecret)
throw new Error("Set BRELLIUM_CLIENT_ID and BRELLIUM_CLIENT_SECRET");
const tokens = tokenManagerFor(clientId, clientSecret);
// Two API calls, one token request: the second call reuses the cached token.
for (let i = 0; i < 2; i++) {
const res = await brelliumFetch(tokens, "/v2/question_sets?limit=1");
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const page = (await res.json()) as { data: { set_name: string }[] };
console.log(res.status, page.data[0]?.set_name ?? "(no question sets yet)");
}
}
main();
Save it as brellium-token.ts and run it with bun run brellium-token.ts, or on Node with node --experimental-strip-types brellium-token.ts (add @types/node to type-check it).
# Keep credentials on the server; never in a browser or mobile app.
import os
import threading
import time
import requests
BASE_URL = "https://api.brellium.com"
AUDIENCE = os.environ.get("BRELLIUM_AUDIENCE", "https://api.brellium.dev")
# Refresh five minutes early, so a request never carries a token that expires
# mid-call.
EARLY_REFRESH_SECONDS = 300
def _retry_after_seconds(res: requests.Response) -> float:
"""Seconds to wait after a 429: Retry-After, else RateLimit-Reset, else one
second.
"""
header = (
res.headers.get("Retry-After") or res.headers.get("RateLimit-Reset") or ""
)
try:
return max(float(header), 1.0)
except ValueError:
return 1.0
class TokenManager:
"""One instance per client_id. Share it across threads; never share a token
between keys.
"""
def __init__(self, client_id: str, client_secret: str) -> None:
self._client_id = client_id
self._client_secret = client_secret
self._token: str | None = None
self._expires_at = 0.0
# One mint at a time; concurrent callers wait for it.
self._lock = threading.Lock()
def get(self) -> str:
"""The cached token, or one mint that every concurrent caller waits for."""
with self._lock:
if self._token and time.monotonic() < self._expires_at:
return self._token
return self._mint()
def invalidate(self, token: str) -> None:
"""Drop a token the API rejected, so the next get() mints. A newer token is
left alone.
"""
with self._lock:
if self._token == token:
self._token = None
def _mint(self) -> str:
attempt = 0
while True:
res = requests.post(
f"{BASE_URL}/v2/oauth/token",
headers={"Content-Type": "application/json"},
json={
"grant_type": "client_credentials",
"client_id": self._client_id,
"client_secret": self._client_secret,
"audience": AUDIENCE,
},
timeout=30,
)
if res.status_code == 429 and attempt < 2:
# The token endpoint's own limit: wait exactly what the header says.
time.sleep(_retry_after_seconds(res))
attempt += 1
continue
if not res.ok:
# Log the status and request id only, never the body of a token
# request or response.
raise RuntimeError(
f"token request failed: {res.status_code} "
f"(x-request-id {res.headers.get('x-request-id')})"
)
body = res.json()
self._token = body["access_token"]
self._expires_at = (
time.monotonic() + body["expires_in"] - EARLY_REFRESH_SECONDS
)
return self._token
_managers: dict[str, TokenManager] = {}
_managers_lock = threading.Lock()
def token_manager_for(client_id: str, client_secret: str) -> TokenManager:
"""One cache per client_id: a token minted for one key is never sent on behalf
of another.
"""
with _managers_lock:
if client_id not in _managers:
_managers[client_id] = TokenManager(client_id, client_secret)
return _managers[client_id]
def brellium_request(
tokens: TokenManager, method: str, path: str, **kwargs
) -> requests.Response:
"""Send an authenticated request. On 401, drop the token, mint once and retry
once.
"""
kwargs.setdefault("timeout", 30)
def send(token: str) -> requests.Response:
headers = {**kwargs.get("headers", {}), "Authorization": f"Bearer {token}"}
return requests.request(
method, f"{BASE_URL}{path}", **{**kwargs, "headers": headers}
)
token = tokens.get()
res = send(token)
if res.status_code == 401:
tokens.invalidate(token)
token = tokens.get()
res = send(token)
return res
if __name__ == "__main__":
tokens = token_manager_for(
os.environ["BRELLIUM_CLIENT_ID"], os.environ["BRELLIUM_CLIENT_SECRET"]
)
# Two API calls, one token request: the second call reuses the cached token.
for _ in range(2):
res = brellium_request(tokens, "GET", "/v2/question_sets?limit=1")
if not res.ok:
raise RuntimeError(f"{res.status_code} {res.text}")
page = res.json()
print(
res.status_code,
page["data"][0]["set_name"]
if page["data"]
else "(no question sets yet)",
)
Save it as brellium_token.py and run it with python3 brellium_token.py.
The script calls GET /v2/question_sets?limit=1 twice and sends one token request; the second call reuses the cached token. With a sandbox key it prints:
200 Progress Note Compliance
200 Progress Note Compliance
With a live key, each line shows the name of your organization's most recent question set, or (no question sets yet) if it has none.
Get a manager from tokenManagerFor() or token_manager_for(), which returns one instance per client_id for the life of the process.
Verify the token and handle a 401
Send the script's request, GET /v2/question_sets?limit=1, with the token from step 1. A 200 means the token is valid for your organization. In the curl command, --write-out prints the HTTP status after the body.
curl "https://api.brellium.com/v2/question_sets?limit=1" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--write-out "HTTP %{http_code}\n"
const res = await fetch("https://api.brellium.com/v2/question_sets?limit=1", {
headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${res.status} ${error.code}: ${error.message}`);
}
console.log(await res.json(), `HTTP ${res.status}`);
import BrelliumClient, { APIError } from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
try {
const questionSets = await client.questionSets.list({ limit: 1 });
console.log(questionSets);
} catch (err) {
if (err instanceof APIError && err.status === 401) {
console.error(
"401: invalidate the token, mint a new one, retry once",
err.error,
);
} else {
throw err;
}
}
import os
import sys
from brellium_api_client import AuthenticationError, BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
try:
question_sets = client.question_sets.list(limit=1)
print(question_sets.to_json())
except AuthenticationError as err: # 401
print(
"401: invalidate the token, mint a new one, retry once",
err.body,
file=sys.stderr,
)
{
"object": "list",
"data": [
{
"set_id": "3c2b1a09-8f7e-4d6c-b5a4-938271605f4e",
"object": "question_set",
"set_name": "Progress Note Compliance"
// the other fields are in Retrieve an audit's question set
}
],
"has_more": true
}
With a sandbox key, has_more is false, because the sample data has one question set. An organization with no question sets gets { "object": "list", "data": [], "has_more": false }, also with a 200. After the token outlives expires_in, or if it was altered in transit, the same request returns HTTP 401:
{
"error": {
"type": "authentication_error",
"code": "UNAUTHORIZED",
"message": "Invalid or expired access token",
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}
The 401 body does not say whether the token expired or was altered. brelliumFetch and brellium_request treat every 401 the same way: invalidate the token, re-mint once, retry once. A second 401 means the API rejects tokens minted with this key, so stop retrying. Send Brellium support the request_id (also the x-request-id response header) and the time of the request.
A revoked key fails at the mint instead: the token endpoint returns 401 with an access_denied body, and the token manager throws token request failed: 401. A 502 BAD_GATEWAY with the message Token endpoint temporarily unavailable means the token request did not go through. The token manager then throws token request failed: 502; retry with backoff.
Wait Retry-After on a 429 from the token endpoint
A cached token never hits this limit; the token manager meets it only when other processes mint with the same key. POST /v2/oauth/token allows a burst of 20 requests per client_id, then one every 10 seconds; the 21st returns 429 with Retry-After:
{
"error": {
"type": "rate_limit_error",
"code": "RATE_LIMITED",
"message": "Too many token requests. Cache and reuse the access token until it expires.",
"request_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
}
Retry-After is in seconds and equals RateLimit-Reset (about 10 seconds after a full burst). The token manager sleeps exactly that long and retries, twice at most. If a 429 has no Retry-After header, wait RateLimit-Reset seconds, or one second when that is missing too, and retry.
While you roll out the token manager, watch RateLimit-Remaining on token responses. It stays near 20 with a working cache and falls when a process still mints per request.
Rotating a secret does not end tokens already issued. For the rotation steps, see Rotate a secret without downtime.
Common mistakes
| Mistake | What you see | Fix |
|---|---|---|
| More than 20 processes each minting at boot | 429 RATE_LIMITED after every deploy |
Share one token per client_id across processes by storing it in a cache entry or a database row, or by running one token service. |
| One cache for several keys | Records from the wrong organization, or 404 NOT_FOUND for ids another key can read |
Key the cache by client_id; a token is bound to its key's organization. |
| Logging the token response | Bearer tokens in your log store | Log x-request-id, the status and RateLimit-Remaining, and never the token response body. |
Next
Handle rate limits and retries
Read the RateLimit headers, back off on 429, retry only safe calls, and tell a 403 from a 429.
Authenticate with OAuth2 client credentials
Create a key, mint a token with it, cache the token, and handle 401, 429 and 502.
Make your first request
Mint a token, make a read-only call, read your first audit.