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_ID and $BRELLIUM_CLIENT_SECRET from 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 set Progress 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