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:
- When
Retry-Afteris 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. - When
Retry-Afteris absent, back off exponentially with jitter (1 s, 2 s, 4 s, 8 s, capped at 30 s). Treat a500or502the same way. - Cap the attempts and the total time, then report the error. After a
5xxon 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=orupdated_after=so you fetch fewer pages (Page through and filter lists). - Ask for
limit=100. A backfill of 10,000 audits takes 100 requests atlimit=100and 500 at the default of 20. - Sync with
updated_afterinstead of fetching every audit again (Keep audits in sync with updated_after). - Watch
RateLimit-Remainingand slow down as it approaches0instead of waiting for the429.
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"]: alimitover 100 is a400, not a page of 100. Sendlimit=100and page withstarting_after.Invalid ISO datetime, path["created_after"](orupdated_after,created_before,updated_before): send an RFC 3339 timestamp in UTC ending inZ, such as2026-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 returns404 NOT_FOUND, and a well-formed cursor that matches nothing returns an empty200page.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 anemailat brellium.com or its subdomains. The envelope'smessageis the same text, andparamisemail.content_type must be one of ..., path["documents", 0, "content_type"]: every document onPOST /v2/documentsneeds acontent_typefrom the accepted list, and the file name's extension does not count. The same endpoint returnsexternal_id values must be unique within a requestanda zip must be the only document in the request(Upload documents).Malformed JSON in request body, codeHTTP_ERROR: the body has a trailing comma or a missing quote, orContent-Type: application/jsoncame with no body. This error has nodetails. Any other content type, or none, is rejected first with400 INVALID_CONTENT_TYPE.- Sending
starting_afterandending_beforetogether 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"], onPATCH /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
Handle rate limits and retries
Read the RateLimit headers, back off on 429, retry only safe calls, and tell a 403 from a 429.
Mint one access token and reuse it
Mint a token once, cache it until expires_in, re-mint on 401, wait on 429.
Page through and filter lists
Cursor pagination, batch get by id, and each resource's filters.
Authenticate with OAuth2 client credentials
Create a key, mint a token with it, cache the token, and handle 401, 429 and 502.