Authenticate with OAuth2 client credentials

The API has one authentication method: the OAuth2 client-credentials grant. Create a key, mint an access token at POST /v2/oauth/token with the key's client id and client secret, and send the token as Authorization: Bearer <token> on every /v2 request. Cache the token and reuse it until it expires.

A token is bound to one organization, and every response is limited to that organization's records. The client_id is not a secret; the client secret and the access token are. Anyone who has a token can call every endpoint for your organization until the token expires; a token cannot be revoked early. Keep your client secret and access tokens on your servers, never in browser or mobile app code.

Get access to the dashboard

You create and manage keys in the API dashboard at platform.brellium.dev. To use it, you need API platform access in your organization. If the dashboard shows "No access to manage API keys" or "You don't have API platform access in this organization", you do not have it yet.

  1. Ask an administrator of your organization in Brellium to turn on your access. In Brellium, they open Control Center > User Directory, select you, and turn on the API platform access switch on the Attributes tab.
  2. Sign in at platform.brellium.dev with your Brellium account.

Access is per organization: if you belong to more than one, you need it in each one where you create keys. For the permissions the administrator needs and the full steps, see Get access to the Brellium API dashboard.

Create a key

Open API keys in the dashboard. The page needs API platform access (Get access to the dashboard).

  1. Click Create key and name it. Under Environment, choose Production for a live key or Sandbox for a sandbox key. A live key works on your organization's records; a sandbox key works on sample data (Test with a sandbox key). You cannot change the organization or the environment later.
  2. Copy the Client ID, the Client secret and the Audience (https://api.brellium.dev for a live key, https://api.brellium.dev/sandbox for a sandbox key). 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.
  3. Store the secret in a secrets manager and export both values:
export BRELLIUM_CLIENT_ID=your-client-id
read -s BRELLIUM_CLIENT_SECRET && export BRELLIUM_CLIENT_SECRET

The key list marks each sandbox key with a Sandbox badge. Revoke key is in the same row menu as Rotate secret; read Rotate a secret without downtime before you use either in production.

A v1 key appears in the list with an API v1 badge. Its row menu has Rotate secret only, and the dashboard asks you to confirm first, because the new secret replaces the old one on v1 as well as v2. To revoke a v1 key, contact Brellium support. It mints tokens for v1 and v2 with the same client id and secret. For how v1 and v2 relate, see Versioning and compatibility.

Mint a token

Send grant_type (client_credentials), client_id, client_secret and the key's audience as a JSON body with Content-Type: application/json. The body has no organization field, because the key is bound to one organization.

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\": \"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: "https://api.brellium.dev",
  }),
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body)}`);
console.log(body);
{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 86400,
  "scope": "documents:read documents:write audits:read ..."
}

The response has the token in access_token and its lifetime in expires_in, in seconds (about a day). Read expires_in from every response instead of hardcoding it, and treat access_token as an opaque string. A token minted with the Authorize button on the API reference counts against the key's POST /v2/oauth/token rate limit.

Mint a token once and reuse it

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 (Rate limits and errors). To stay under that limit, mint one token per key and reuse it under these rules:

  • Compute the expiry from expires_in, not from the token's contents: expires_at = now + expires_in - 300. The five-minute margin covers a request in flight.
  • Re-mint only when now >= expires_at. The old token works until its own expiry, so there is no gap.
  • Keep one cache per key, readable by every process that uses the key, so a fleet restart does not mint dozens of tokens at once.
  • Re-mint once on a 401 and retry the request once; if it fails again, report the error. Never put the token request inside a per-call retry loop.
  • On a 429 from POST /v2/oauth/token, wait exactly Retry-After seconds and try once more.
  • Expect issued tokens to keep working after you rotate or revoke the key (Rotate a secret without downtime).
  • Keep the secret and the token out of your logs.
// Keep credentials on the server; never in a browser or mobile app.
const BASE_URL = "https://api.brellium.com";

type CachedToken = { token: string; expiresAt: number };
let cached: CachedToken | undefined;

async function mintToken(): Promise<CachedToken> {
  for (let attempt = 0; attempt < 2; 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: process.env.BRELLIUM_CLIENT_ID,
        client_secret: process.env.BRELLIUM_CLIENT_SECRET,
        audience: "https://api.brellium.dev",
      }),
    });
    if (res.status === 429 && attempt === 0) {
      // Wait exactly what the server asks for, then try once more.
      const seconds = Number(res.headers.get("Retry-After") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
      continue;
    }
    if (!res.ok) {
      throw new Error(`token request failed: ${res.status} ${await res.text()}`);
    }
    const body = (await res.json()) as { access_token: string; expires_in: number };
    return {
      token: body.access_token,
      // Refresh five minutes before the token expires.
      expiresAt: Date.now() + body.expires_in * 1000 - 300_000,
    };
  }
  throw new Error("token endpoint rate limited twice; back off before retrying");
}

async function getToken(): Promise<string> {
  if (!cached || Date.now() >= cached.expiresAt) cached = await mintToken();
  return cached.token;
}

/** Call the API with the cached token; on a 401, re-mint once and retry. */
export async function api(path: string, init: RequestInit = {}): Promise<Response> {
  const send = async () => {
    const headers = new Headers(init.headers);
    headers.set("Authorization", `Bearer ${await getToken()}`);
    return fetch(`${BASE_URL}${path}`, { ...init, headers });
  };
  let res = await send();
  if (res.status === 401) {
    cached = undefined;
    res = await send();
  }
  return res;
}
# Keep credentials on the server; never in a browser or mobile app.
import os
import time

import requests

BASE_URL = "https://api.brellium.com"
_cached = None  # (token, expires_at)


def _mint():
    for attempt in range(2):
        res = requests.post(
            f"{BASE_URL}/v2/oauth/token",
            json={
                "grant_type": "client_credentials",
                "client_id": os.environ["BRELLIUM_CLIENT_ID"],
                "client_secret": os.environ["BRELLIUM_CLIENT_SECRET"],
                "audience": "https://api.brellium.dev",
            },
            timeout=10,
        )
        if res.status_code == 429 and attempt == 0:
            # Wait exactly what the server asks for, then try once more.
            time.sleep(float(res.headers.get("Retry-After", "1")))
            continue
        res.raise_for_status()
        body = res.json()
        # Refresh five minutes before the token expires.
        return body["access_token"], time.time() + body["expires_in"] - 300
    raise RuntimeError(
        "token endpoint rate limited twice; back off before retrying"
    )


def get_token():
    global _cached
    if _cached is None or time.time() >= _cached[1]:
        _cached = _mint()
    return _cached[0]


def api(method, path, **kwargs):
    """Call the API with the cached token; on a 401, re-mint once and retry."""
    global _cached
    extra_headers = kwargs.pop("headers", {})

    def send():
        headers = {**extra_headers, "Authorization": f"Bearer {get_token()}"}
        return requests.request(
            method, f"{BASE_URL}{path}", headers=headers, timeout=30, **kwargs
        )

    res = send()
    if res.status_code == 401:
        _cached = None
        res = send()
    return res

Authentication errors

A token request or a /v2 request can fail with one of these responses:

  • 401 UNAUTHORIZED with Missing or malformed Authorization header means the Authorization header is missing or is not Bearer <token>.
  • 401 UNAUTHORIZED with Invalid or expired access token means the token is expired, incomplete, tampered with, minted for another audience, or not bound to an organization. The body does not say which. Re-mint once and retry the request once.
  • 401 ENVIRONMENT_MISMATCH means the token was minted for the other environment (live or sandbox). Re-minting does not help, so send Brellium support the request_id.
  • 401 SANDBOX_UNAVAILABLE means the sandbox is not available, so the API refuses every sandbox token (Test with a sandbox key).
  • access_denied from POST /v2/oauth/token comes in the OAuth error format shown below instead of the API's error envelope. A wrong client_secret or an unknown client_id returns 401 without saying which. A wrong or missing audience returns 403, and error_description names the problem. The body has no request_id, so send Brellium support the x-request-id response header instead.
  • 429 RATE_LIMITED from POST /v2/oauth/token means the key hit the token endpoint's rate limit. Wait Retry-After seconds and try once more. If a 429 has no Retry-After header, wait a few seconds and retry once.
  • 502 BAD_GATEWAY with Token endpoint temporarily unavailable means the token request did not go through. Your cached token keeps working, so retry the mint with backoff. Retry any other 5xx from POST /v2/oauth/token with backoff.
{
  "error": "access_denied",
  "error_description": "Unauthorized"
}

Rotate a secret without downtime

Rotation changes which secret can mint new tokens. A token minted before the rotation keeps working until it expires. Rotate the secret in three steps:

  1. On API keys, open the key's row menu, choose Rotate secret and copy the new secret. For up to 30 seconds after a rotation, the token endpoint can accept either the old secret or the new one.
  2. Deploy the new secret before the cached token expires. Running processes keep using the token they hold until their next mint at expires_at.
  3. Watch the next mint. Once those 30 seconds have passed, an access_denied means a process still has the old secret.

If a secret may have leaked, rotate it immediately; an issued token cannot be revoked early. Revoke key deletes the key permanently and stops future mints, but tokens already issued run to their expiry. The dashboard's confirmation that the key will "stop working immediately" applies to new mints only.

Scopes

Every key can call every endpoint, so you can ignore the scope field of the token response. When your organization requires mutual TLS, a /v2 call on api.brellium.com returns 403 MTLS_REQUIRED. See Mutual TLS. For the causes of a bare 403 with no JSON body and no x-request-id, see Rate limits and errors.

Next