Rate limits and errors

Every /v2 request draws from your organization's token bucket, and every token request draws from a stricter bucket for its key. An empty bucket returns 429 with Retry-After. Every error from a /v2 endpoint, whatever its status, is one JSON envelope with a stable code to branch on and a request_id to send to Brellium support.

Rate limits

Limits

What Keyed by Burst Sustained
Every /v2 request with a live key Organization: all live keys of the organization share one bucket 100 5 per second
Every /v2 request with a sandbox key Organization: all sandbox keys of the organization share a second bucket 100 5 per second
POST /v2/oauth/token client_id (source IP when the request carries none) 20 One every 10 seconds (6 per minute)

Live and sandbox requests draw from separate buckets with the same limits (Test with a sandbox key). The token limit is per key, so a sandbox key and a live key never share that bucket either. That limit is tight because a token lasts about a day: mint one token per key and reuse it (Authenticate with OAuth2 client credentials).

The token bucket

Each bucket starts full, and every authenticated request takes one token, whether it ends in 200, 400 or 404. Tokens come back continuously at the refill rate, up to the burst capacity, so there is no fixed reset time. A request passes when at least one whole token is available. A short spike is fine, and steady traffic settles at the refill rate. These limits are maximums; Brellium does not guarantee you can always reach them.

Headers

Every authenticated /v2 response and every POST /v2/oauth/token response carries RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset. A 429 adds Retry-After.

Header Value
RateLimit-Limit The bucket's burst capacity (100 on /v2, 20 on /v2/oauth/token).
RateLimit-Remaining Whole tokens left after this request. When it is 0 on a 200, the next request passes only if a token refills first.
RateLimit-Reset 0 when the request was allowed. On a 429, the whole seconds until one token is back.
Retry-After On 429 only. The same number of seconds as RateLimit-Reset. On /v2 it is always 1; on /v2/oauth/token it is between 1 and 10.

A 401 from a /v2 endpoint carries no RateLimit-* headers and does not count against the limit.

A 429 response

When your organization's bucket is empty, a list request such as this one returns a 429.

curl --include "https://api.brellium.com/v2/audits?status=complete&limit=100" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
const params = new URLSearchParams({ status: "complete", limit: "100" });
const res = await fetch(`https://api.brellium.com/v2/audits?${params}`, {
  headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
});
// Like curl --include: the status and headers, then the body.
console.log(res.status, res.statusText);
for (const [name, value] of res.headers) console.log(`${name}: ${value}`);
console.log(await res.json());
import BrelliumClient, { APIError } from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

try {
  // maxRetries: 0 shows the 429; by default the client waits Retry-After and
  // retries.
  const { data, response } = await client.audits
    .list({ status: "complete", limit: 100 }, { maxRetries: 0 })
    .withResponse();
  console.log(response.status, response.headers.get("RateLimit-Remaining"));
  console.log(data);
} catch (err) {
  if (err instanceof APIError) {
    console.log(err.status, err.headers?.get("Retry-After"), err.error);
  }
  throw err;
}
import os

from brellium_api_client import APIStatusError, BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

try:
    # max_retries=0 shows the 429; by default the client waits Retry-After and
    # retries.
    raw = client.with_options(max_retries=0).audits.with_raw_response.list(
        status="complete", limit=100
    )
    print(raw.status_code, raw.headers.get("RateLimit-Remaining"))
    print(raw.parse().to_json())
except APIStatusError as err:
    print(err.status_code, err.response.headers.get("Retry-After"), err.body)
    raise
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
x-request-id: 7f0b3c1e-2a4d-4b8e-9c11-5d6e7f8a9b0c
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1
Retry-After: 1

The body is the envelope with type rate_limit_error and code RATE_LIMITED. A 429 from POST /v2/oauth/token has the same type and code with its own message, listed in Every code.

Retry a 429

Apply these rules to every 429:

  1. When Retry-After is present, wait that many seconds plus a short random delay, then retry the same request. The delay (up to 500 ms in the samples) keeps processes that share your organization's rate limit from retrying at the same moment.
  2. When Retry-After is absent, back off exponentially with jitter (1 s, 2 s, 4 s, 8 s, capped at 30 s). Treat a 500 or 502 the same way.
  3. Cap the attempts and the total time, then report the error. After a 5xx on a write you don't know whether the write happened, so fetch the record before you write again.

Retry only idempotent reads (GET) automatically. A 400 fails the same way every time, a 401 fails until you mint a new token, and a retry sent before Retry-After has passed can get another 429. These samples wrap the GET from the 429 example in a retry loop and print the status, RateLimit-Remaining and x-request-id.

// Keep credentials on the server; never in a browser or mobile app.
const BASE_URL = "https://api.brellium.com";
const TOKEN = process.env.BRELLIUM_ACCESS_TOKEN;

const MAX_ATTEMPTS = 5;
const MAX_TOTAL_MS = 60_000;

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

// Retries idempotent reads only: 429 and 5xx, never 400 or 401.
export async function getWithRetry(path: string): Promise<Response> {
  const started = Date.now();
  let attempt = 0;
  while (true) {
    attempt += 1;
    const res = await fetch(`${BASE_URL}${path}`, {
      headers: { Authorization: `Bearer ${TOKEN}` },
    });
    const retryable = res.status === 429 || res.status >= 500;
    if (!retryable || attempt >= MAX_ATTEMPTS) return res;

    const retryAfter = Number(res.headers.get("Retry-After") ?? 0);
    const backoffMs = Math.min(30_000, 1_000 * 2 ** (attempt - 1));
    const jitterMs = Math.random() * 500;
    const waitMs =
      retryAfter > 0 ? retryAfter * 1_000 + jitterMs : backoffMs + jitterMs;

    if (Date.now() - started + waitMs > MAX_TOTAL_MS) return res;
    await sleep(waitMs);
  }
}

async function main(): Promise<void> {
  const res = await getWithRetry("/v2/audits?status=complete&limit=100");
  console.log(
    res.status,
    res.headers.get("RateLimit-Remaining"),
    res.headers.get("x-request-id"),
  );
}

main();
# Keep credentials on the server; never in a browser or mobile app.
import os
import random
import time

import requests

BASE_URL = "https://api.brellium.com"
TOKEN = os.environ["BRELLIUM_ACCESS_TOKEN"]

MAX_ATTEMPTS = 5
MAX_TOTAL_SECONDS = 60.0


def get_with_retry(path: str) -> requests.Response:
    """Retries idempotent reads only: 429 and 5xx, never 400 or 401."""
    started = time.monotonic()
    attempt = 0
    while True:
        attempt += 1
        res = requests.get(
            f"{BASE_URL}{path}",
            headers={"Authorization": f"Bearer {TOKEN}"},
            timeout=30,
        )
        retryable = res.status_code == 429 or res.status_code >= 500
        if not retryable or attempt >= MAX_ATTEMPTS:
            return res

        retry_after = float(res.headers.get("Retry-After") or 0)
        backoff = min(30.0, 2.0 ** (attempt - 1))
        jitter = random.uniform(0, 0.5)
        wait = retry_after + jitter if retry_after > 0 else backoff + jitter

        if time.monotonic() - started + wait > MAX_TOTAL_SECONDS:
            return res
        time.sleep(wait)


res = get_with_retry("/v2/audits?status=complete&limit=100")
print(
    res.status_code,
    res.headers.get("RateLimit-Remaining"),
    res.headers.get("x-request-id"),
)

For a complete request wrapper with pacing and capped backoff, see Handle rate limits and retries. For token caching and the re-mint on 401, see Mint one access token and reuse it.

Stay under the limit

These habits keep your traffic under both limits:

  • Mint one token per key, cache it for its expires_in, and share it with every process that uses that key.
  • Filter in the request with status=, set_id=, documents= or updated_after= so you fetch fewer pages (Page through and filter lists).
  • Ask for limit=100. A backfill of 10,000 audits takes 100 requests at limit=100 and 500 at the default of 20.
  • Sync with updated_after instead of fetching every audit again (Keep audits in sync with updated_after).
  • Watch RateLimit-Remaining and slow down as it approaches 0 instead of waiting for the 429.

Responses without an envelope

A bare 403, 413, 414 or 431 has no JSON body and no x-request-id. A 413, 414 or 431 means the body, the URL or the headers were too large.

A 403 with a JSON body and the code MTLS_REQUIRED, MTLS_CERTIFICATE_MISMATCH or MTLS_POLICY_UNAVAILABLE comes from the mutual TLS check (Mutual TLS). Any other 403 on a /v2 request is bare. The causes of a bare one are too many requests from one IP address in a short time, a query string over 2,048 bytes, and a request refused for security reasons. In a batch get, 2,048 bytes hold about 44 audit_id values, so split larger batches (Page through and filter lists).

Errors

The envelope

Every error from a /v2 endpoint is one JSON envelope:

{
  "error": {
    "type": "not_found_error",
    "code": "NOT_FOUND",
    "message": "Audit not found",
    "request_id": "7f0b3c1e-2a4d-4b8e-9c11-5d6e7f8a9b0c"
  }
}
Field Stability Meaning
type Stable The error class, set by the HTTP status (see Status, type and cause). 500 and 502 share api_error.
code Stable, but new codes may be added The machine-readable code. Branch on this.
message Can change A readable explanation. Log it, but don't parse it.
param Stable when present The request field the error is about, for example email.
details Can change On a VALIDATION_ERROR, one object per problem, each with a path and a message plus other fields you should not depend on. EMAIL_RECIPIENT_HAS_USER: { "user_id": "..." }. INVITATION_PENDING: { "user_id": "...", "invitation_expires_at": "..." }. INVITATION_EXPIRED: { "user_id": "...", "invitation_expired_at": "..." }. EXTERNAL_ID_CONFLICT from POST /v2/documents: { "external_ids": [...] }. SANDBOX_LIMIT: { "resource": "...", "limit": 5000 }.
request_id Stable The id of this request. Equal to the x-request-id response header.

POST /v2/oauth/token uses this envelope only for RATE_LIMITED and BAD_GATEWAY. Any other error from it, such as access_denied for a wrong secret or an unknown client_id, comes in the OAuth error format instead of this envelope (Authenticate with OAuth2 client credentials).

Status, type and cause

Status type Cause
400 invalid_request_error The request failed validation (VALIDATION_ERROR, with details), its body was not sent as JSON (INVALID_CONTENT_TYPE), or its body could not be parsed (HTTP_ERROR).
401 authentication_error The token is missing or was not accepted. The API checks the token before the rate limit and before it validates the request.
403 permission_error Your organization requires mutual TLS, or the client certificate is not registered to it (MTLS_REQUIRED, MTLS_CERTIFICATE_MISMATCH, MTLS_POLICY_UNAVAILABLE; Mutual TLS).
404 not_found_error The id does not exist in your organization, the path or method does not exist (never a 405), or your organization is not set up for document uploads (ORG_NOT_FOUND).
409 conflict_error The write conflicts with an existing record, or would take the sandbox past its limit (SANDBOX_LIMIT).
429 rate_limit_error You hit a rate limit.
500 api_error An unexpected failure. The body gives no cause, so send Brellium support the request_id (Request ids).
502 api_error The token endpoint is temporarily unavailable (BAD_GATEWAY), or an invitation could not be sent or revoked (INVITE_FAILED, REVOKE_FAILED).
503 api_error The certificate check is starting up (MTLS_POLICY_UNAVAILABLE). Retry shortly.

Every code

Status code message Where
400 VALIDATION_ERROR Request validation failed, except for a brellium.com address (see Common validation errors) Any endpoint.
400 INVALID_CONTENT_TYPE Send the request body as JSON with the header Content-Type: application/json. Any endpoint that takes a body.
400 HTTP_ERROR Malformed JSON in request body Any endpoint with a JSON body.
401 UNAUTHORIZED Missing or malformed Authorization header Every /v2 endpoint.
401 UNAUTHORIZED Invalid or expired access token Every /v2 endpoint. The reason is not returned.
401 ENVIRONMENT_MISMATCH This access token belongs to a sandbox key and cannot be used in the live environment (or the reverse) Every /v2 endpoint. You should not get this code. If you do, send Brellium support the request_id.
401 SANDBOX_UNAVAILABLE The sandbox is not available Every /v2 endpoint, with a sandbox token while the sandbox is unavailable.
403 MTLS_REQUIRED Says your organization requires mutual TLS for this environment and names https://api-mtls.brellium.com Every /v2 endpoint on api.brellium.com. Call the mTLS hostname with your client certificate (Mutual TLS).
403 MTLS_CERTIFICATE_MISMATCH The client certificate is not registered for this organization Every /v2 endpoint on api-mtls.brellium.com, when the certificate belongs to another organization, is bound to another key, or was revoked, blocked or issued under a removed CA.
403 MTLS_POLICY_UNAVAILABLE The certificate policy is temporarily unavailable; retry shortly Every /v2 endpoint. Retry shortly with backoff.
404 NOT_FOUND Audit not found, Document not found, Coding evaluation not found, Question set not found, User not found, Email recipient not found Retrieve, update and delete by id.
404 NOT_FOUND Route not found: <METHOD> <path>, for example Route not found: GET /v2/audits/ No such path or method. Paths are case-sensitive.
404 ORG_NOT_FOUND Organization not found POST /v2/documents, when your organization is not set up for document uploads. Contact Brellium support.
409 EXTERNAL_ID_CONFLICT A document with this external_id already exists in this organization PATCH /v2/documents/{document_id}, when another document already uses the external_id.
409 EXTERNAL_ID_CONFLICT Says that some external_id values are already used by documents from another request; details.external_ids lists them. POST /v2/documents, when only some of the request's external_id values are already in use, or the ones in use belong to more than one batch. Nothing is created. Sending the exact same request again is not a conflict: it returns the documents from the first time. For the external_id rules, see Upload documents.
409 USER_EXISTS A user with this email already exists in this organization POST /v2/users and PATCH /v2/users/{user_id}.
409 INVITATION_PENDING Names the dates the user was invited and the invitation expires; details has user_id and invitation_expires_at. POST /v2/users, when the user was invited and hasn't accepted yet. To send a new invitation now, delete the user, then create them again.
409 INVITATION_EXPIRED Names the dates the user was invited and the invitation expired; details has user_id and invitation_expired_at. POST /v2/users, when the user never accepted an invitation and every invitation has expired. Delete the user, then create them again.
409 EMAIL_RECIPIENT_EXISTS An email recipient with this email already exists.; param is email POST /v2/email_recipients, PATCH /v2/email_recipients/{person_id} and PATCH /v2/users/{user_id}.
409 EMAIL_RECIPIENT_HAS_USER This email recipient is also a dashboard user; change its email with PATCH /v2/users/<user_id> instead.; param is email, details.user_id is the user PATCH /v2/email_recipients/{person_id} with a new address, when the email recipient is also a dashboard user (a user who signs in to Brellium).
409 EMAIL_RECIPIENT_HAS_USER This email recipient is also a dashboard user. Delete the user first with DELETE /v2/users/<user_id>, then delete the email recipient.; details.user_id is the user DELETE /v2/email_recipients/{person_id}.
409 SANDBOX_LIMIT Names the resource and the limit; details has resource and limit Any create, with a sandbox key, that would take the sandbox past 5,000 records of one resource. Nothing is created. Delete some users or email recipients, or reset the sandbox, which is the only way to clear documents.
429 RATE_LIMITED Too many requests Every /v2 endpoint.
429 RATE_LIMITED Too many token requests. Cache and reuse the access token until it expires. POST /v2/oauth/token.
500 INTERNAL_ERROR Internal server error Any endpoint.
502 BAD_GATEWAY Token endpoint temporarily unavailable POST /v2/oauth/token. A cached token keeps working.
502 INVITE_FAILED Could not send the invitation POST /v2/users. The user is kept, so calling again sends the invitation.
502 REVOKE_FAILED Could not revoke the user's invitation DELETE /v2/users/{user_id}. The user is kept, so call again.
503 MTLS_POLICY_UNAVAILABLE The service is starting; retry shortly Every /v2 endpoint. Retry shortly with backoff.

Each operation in the API reference lists the statuses it can return, except 500. A zip that can't be unpacked is not an HTTP error: the document's status becomes extraction_failed with an ARCHIVE_* code in metadata.archive.error (Upload documents).

The batch update endpoints, POST /v2/audits/batch_update and POST /v2/users/batch_update, return 200 and report each failed item in place as { "audit_id": ..., "error": {...} } or { "user_id": ..., "error": {...} }, so check every item. Audit items fail with NOT_FOUND or INTERNAL_ERROR. User items can also fail with the 409 codes of PATCH /v2/users/{user_id}: USER_EXISTS, or EMAIL_RECIPIENT_EXISTS with param email.

Common validation errors

Each VALIDATION_ERROR carries the failing field in details[].path and its message in details[].message. These are the errors you are most likely to hit:

  • Too big: expected number to be <=100, path ["limit"]: a limit over 100 is a 400, not a page of 100. Send limit=100 and page with starting_after.
  • Invalid ISO datetime, path ["created_after"] (or updated_after, created_before, updated_before): send an RFC 3339 timestamp in UTC ending in Z, such as 2026-09-24T02:37:12Z. A date alone or a time with an offset is rejected.
  • Invalid UUID, path ["audit_id"]: the path id is not a UUID, so nothing is looked up. A well-formed unknown id returns 404 NOT_FOUND, and a well-formed cursor that matches nothing returns an empty 200 page.
  • Email addresses at brellium.com can't be added as users. (or ... as email recipients.), path ["email"] (["items", 0, "email"] in a batch update): every write to a user or an email recipient rejects an email at brellium.com or its subdomains. The envelope's message is the same text, and param is email.
  • content_type must be one of ..., path ["documents", 0, "content_type"]: every document on POST /v2/documents needs a content_type from the accepted list, and the file name's extension does not count. The same endpoint returns external_id values must be unique within a request and a zip must be the only document in the request (Upload documents).
  • Malformed JSON in request body, code HTTP_ERROR: the body has a trailing comma or a missing quote, or Content-Type: application/json came with no body. This error has no details. Any other content type, or none, is rejected first with 400 INVALID_CONTENT_TYPE.
  • Sending starting_after and ending_before together is not an error. The API uses only one of them, so an unexpected page can mean you sent both.
  • flags must be unique, path ["flags"], on PATCH /v2/audits/{audit_id}: the list has the same flag twice. It replaces the audit's flags and holds up to 50 unique, non-empty strings (Update audit flags).

Request ids

The same id appears in the x-request-id header on every response and in error.request_id on every error, and Brellium support uses it to find the request. If you send your own X-Request-Id, the API returns it; otherwise the API generates a UUID.

Log the id with every call. Send Brellium support the request_id (also the x-request-id response header) and the time of the request. Leave response bodies out of the report: they can contain protected health information (PHI).

Next