Test end to end with sandbox credentials
Build and test an integration before it touches your organization's records. A sandbox key works on sample data and uses the same base URL, endpoints and response shapes as a live key. The sandbox simulates processing on a fixed schedule and never reads a file you upload.
Prerequisites
- API platform access in the dashboard, which an administrator of your organization turns on in Brellium (Get access to the dashboard).
- An organization that is set up for document uploads. Sandbox and live keys both need this. If it is not set up,
POST /v2/documentsreturns404 ORG_NOT_FOUND; contact Brellium support. - Any file to upload; the sandbox never reads it.
curlandjq, or a TypeScript runtime such as Bun, or Python 3.10 or later withrequests.
Steps
Create a sandbox key
- On API keys, click Create key and name it, for example
Sandbox integration tests. - Under Environment, choose Sandbox.
- Click Create key and copy the Client ID, the Client secret (shown once) and the Audience.
The key gets a Sandbox badge in the list. The scripts read the same three variables for a live key, so export the sandbox key's values where your code runs:
export BRELLIUM_CLIENT_ID=your-sandbox-client-id
read -s BRELLIUM_CLIENT_SECRET && export BRELLIUM_CLIENT_SECRET
export BRELLIUM_AUDIENCE=https://api.brellium.dev/sandbox
Mint a token for the sandbox audience
A sandbox key mints tokens only for https://api.brellium.dev/sandbox. For any other audience, POST /v2/oauth/token returns an access_denied body.
export BRELLIUM_ACCESS_TOKEN=$(curl --silent \
"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\"
}" | jq --raw-output .access_token)
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,
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body)}`);
// Available to the rest of this process, like the exported variable
process.env.BRELLIUM_ACCESS_TOKEN = body.access_token;
The response has the same fields as for a live key (access_token, token_type, expires_in of 86400, scope). Cache the token and reuse it until expires_at = now + expires_in - 300. 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. For the caching code, see Mint one access token and reuse it.
Fetch the sample audit
Your first sandbox request seeds the sample data. List the newest audit:
curl "https://api.brellium.com/v2/audits?limit=1" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
const res = await fetch("https://api.brellium.com/v2/audits?limit=1", {
headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.error)}`);
console.log(body);
import BrelliumClient from "@brellium/api-client";
// The same client as live; the sandbox token picks the sandbox
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const audits = await client.audits.list({ limit: 1 });
console.log(audits);
import os
from brellium_api_client import BrelliumClient
# The same client as live; the sandbox token picks the sandbox
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
audits = client.audits.list(limit=1)
print(audits.to_json())
{
"object": "list",
"data": [
{
"audit_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"object": "audit",
"status": "complete",
"score": 0.6666666666666666,
"flags": ["Failed", "Missing Signature"],
"documents": ["0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b"],
"display_info": [
/* ... */
], // display_info, people and responses as in Retrieve an audit by id
"people": [ /* ... */ ],
"responses": [ /* ... */ ]
}
],
"has_more": false
}
Before your first upload, or after a reset, this is the only audit. The sample ids match the API reference, so every GET example in the reference runs against the sandbox as written. For the full audit, see Retrieve an audit by id; for every field, see Understand an audit.
Upload a document
Create the document with POST /v2/documents. content_type is required, and it alone sets the file type, whatever the extension in filename. Use a new external_id per run, because a repeated external_id replays the first request instead of creating a document.
curl "https://api.brellium.com/v2/documents" \
--request POST \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"documents": [
{
"filename": "2026-09-23-session-note.pdf",
"content_type": "application/pdf",
"external_id": "sandbox-test-0001"
}
]
}'
const res = await fetch("https://api.brellium.com/v2/documents", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
documents: [
{
filename: "2026-09-23-session-note.pdf",
content_type: "application/pdf",
external_id: "sandbox-test-0001",
},
],
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.error)}`);
console.log(body);
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const documents = await client.documents.create({
documents: [
{
filename: "2026-09-23-session-note.pdf",
content_type: "application/pdf",
external_id: "sandbox-test-0001",
},
],
});
console.log(documents);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
documents = client.documents.create(
documents=[
{
"filename": "2026-09-23-session-note.pdf",
"content_type": "application/pdf",
"external_id": "sandbox-test-0001",
}
],
)
print(documents.to_json())
The response is 201 with the document in pending_upload and its upload URL:
{
"object": "list",
"data": [
{
"document_id": "5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b",
"object": "document",
"filename": "2026-09-23-session-note.pdf",
"status": "pending_upload",
"group_id": "8b9c0d1e-2f3a-4b5c-9d6e-7f8a9b0c1d2e",
"external_id": "sandbox-test-0001",
"metadata": {
// set by Brellium, read-only
"related_doc_ids": ["5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b"],
"is_leading_doc": true
},
/* ... */ // the other document fields
"upload": {
"url": "https://uploads.example.com/5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b?signature=...",
"method": "PUT",
"headers": { "Content-Type": "application/pdf" },
"expires_at": "2026-10-27T15:15:00.000Z"
}
}
],
"has_more": false
}
Set UPLOAD_URL to upload.url and DOCUMENT_ID to document_id, because the snippets that follow read both. PUT the file to upload.url within 15 minutes with every header in upload.headers and no Authorization header. There is no confirm call.
curl --request PUT "$UPLOAD_URL" \
--header "Content-Type: application/pdf" \
--upload-file 2026-09-23-session-note.pdf
import { readFile } from "node:fs/promises";
// No Authorization header: the URL carries its own signature
const res = await fetch(process.env.UPLOAD_URL!, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: await readFile("2026-09-23-session-note.pdf"),
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
A 200 with an empty body means the file arrived. A 403 from the upload URL is XML from the storage service. It means the URL has expired or the Content-Type differs from the one in upload.headers. For batches, retries and zips, see Upload documents.
Watch the document move from todo to complete
The sandbox simulates processing on a fixed schedule. After your upload, the next read returns the document as todo. Thirty seconds after that, reads return it as complete. Poll the document until its status is complete:
curl "https://api.brellium.com/v2/documents/$DOCUMENT_ID" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" | jq .status
const res = await fetch(
`https://api.brellium.com/v2/documents/${process.env.DOCUMENT_ID}`,
{
headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
},
);
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.error)}`);
console.log(body.status);
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const document = await client.documents.retrieve(process.env.DOCUMENT_ID!);
console.log(document.status);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
document = client.documents.retrieve(os.environ["DOCUMENT_ID"])
print(document.status)
| When | status |
|---|---|
| Before the file arrives | pending_upload |
| After the file arrives | todo |
30 seconds after it became todo |
complete |
| A file still missing 24 hours after the request | upload_expired, and the external_id is released |
Fetch the document's audit
A complete document gets an audit. With the default grouping (separate), each document gets its own audit; with "grouping": "group", the batch gets one audit once every document in it is complete. List the audits that cover the document:
curl "https://api.brellium.com/v2/audits?documents=$DOCUMENT_ID" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
| jq '.data[0]
| {audit_id, status, score, flags, documents, created_at, reviewed_at}'
const query = new URLSearchParams({ documents: process.env.DOCUMENT_ID! });
const res = await fetch(`https://api.brellium.com/v2/audits?${query}`, {
headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
});
const body = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(body.error)}`);
const { audit_id, status, score, flags, documents, created_at, reviewed_at } =
body.data[0];
console.log({ audit_id, status, score, flags, documents, created_at, reviewed_at });
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const audits = await client.audits.list({ documents: process.env.DOCUMENT_ID! });
const { audit_id, status, score, flags, documents, created_at, reviewed_at } =
audits.data[0];
console.log({ audit_id, status, score, flags, documents, created_at, reviewed_at });
import json
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
audits = client.audits.list(documents=os.environ["DOCUMENT_ID"])
audit = audits.data[0]
print(
json.dumps(
{
"audit_id": audit.audit_id,
"status": audit.status,
"score": audit.score,
"flags": audit.flags,
"documents": audit.documents,
"created_at": audit.created_at,
"reviewed_at": audit.reviewed_at,
},
indent=2,
default=str,
)
)
{
"audit_id": "1f2e3d4c-5b6a-4978-8a9b-0c1d2e3f4a5b",
"status": "complete",
"score": 0.6666666666666666,
"flags": ["Failed", "Missing Signature"],
"documents": ["5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b"],
"created_at": "2026-10-27T15:00:42.318Z",
"reviewed_at": null
}
The audit is the sample audit with a new audit_id, new response_id values and your document in documents. Its created_at and updated_at are the time the document completed. Because the sandbox never reads your file, every upload gets the sample audit's responses.
Run the whole flow as one script
The script mints a token, fetches the newest audit, uploads a file, polls until an audit covers the document, and resets the sandbox when it runs with the sandbox audience. It reads the key and the audience from the variables you exported in the first step, and an unset BRELLIUM_AUDIENCE means live. Pass the file to upload as the first argument, for example bun run sandbox-e2e.ts ./note.pdf or python3 sandbox_e2e.py ./note.pdf; without one, the script uploads 2026-09-23-session-note.pdf from the current directory. To run the same code against your organization's records, export a live key and audience as in Switch to live.
// Keep credentials on the server; never in a browser or mobile app.
import { readFile } from "node:fs/promises";
const BASE_URL = "https://api.brellium.com";
const SANDBOX_AUDIENCE = "https://api.brellium.dev/sandbox";
const AUDIENCE = process.env.BRELLIUM_AUDIENCE ?? "https://api.brellium.dev";
const FILE = process.argv[2] ?? "2026-09-23-session-note.pdf";
// In the sandbox, poll until `complete`; it takes about 30 s after the upload. Live
// processing takes longer.
const TIMEOUT_MS = AUDIENCE === SANDBOX_AUDIENCE ? 120_000 : 3_600_000;
type Upload = {
url: string;
method: "PUT";
headers: Record<string, string>;
expires_at: string;
};
type Document = {
document_id: string;
status: string;
group_id: string | null;
upload: Upload | null;
};
type Audit = {
audit_id: string;
status: string;
score: number | null;
flags: string[];
documents: string[];
};
type Page<T> = { object: "list"; data: T[]; has_more: boolean };
async function mintToken(): Promise<string> {
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: AUDIENCE,
}),
});
if (!res.ok)
throw new Error(`token request failed: ${res.status} ${await res.text()}`);
return ((await res.json()) as { access_token: string }).access_token;
}
async function main(): Promise<void> {
const token = await mintToken();
const api = async <T>(
method: string,
path: string,
body?: unknown,
): Promise<T> => {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (res.status === 204) return undefined as T;
const json = await res.json();
if (!res.ok) {
const { error } = json as {
error: { code: string; message: string; request_id?: string };
};
throw new Error(
`${method} ${path}: ${res.status} ${error.code} ${error.message} ` +
`(request_id ${error.request_id})`,
);
}
return json as T;
};
// 1. Read the newest audit. Before your first upload, or after a reset, it is the
// sample audit.
const audits = await api<Page<Audit>>("GET", "/v2/audits?limit=1");
console.log("newest audit:", audits.data[0]?.audit_id);
// 2. Create one document. A new external_id per run keeps the request from
// replaying an earlier one.
const created = await api<Page<Document>>("POST", "/v2/documents", {
documents: [
{
filename: "2026-09-23-session-note.pdf",
content_type: "application/pdf",
external_id: `sandbox-test-${Date.now()}`,
},
],
});
const doc = created.data[0];
if (!doc?.upload) throw new Error("no upload URL returned");
console.log("created", doc.document_id, "in batch", doc.group_id);
// 3. PUT the file with the headers given. No Authorization header: the URL is the
// credential.
const put = await fetch(doc.upload.url, {
method: doc.upload.method,
headers: doc.upload.headers,
body: await readFile(FILE),
});
if (!put.ok) throw new Error(`upload failed: ${put.status}`);
// 4. Poll the document and its audits until an audit covering it is complete.
const deadline = Date.now() + TIMEOUT_MS;
let lastStatus = "";
let audit: Audit | undefined;
while (!audit && Date.now() < deadline) {
const current = await api<Document>("GET", `/v2/documents/${doc.document_id}`);
if (current.status !== lastStatus) {
lastStatus = current.status;
console.log("document status:", lastStatus);
}
if (current.status === "upload_expired") throw new Error("the upload expired");
const found = await api<Page<Audit>>(
"GET",
`/v2/audits?documents=${doc.document_id}`,
);
audit = found.data.find((a) => a.status === "complete");
if (!audit) await new Promise((resolve) => setTimeout(resolve, 5_000));
}
if (!audit) throw new Error(`no complete audit within ${TIMEOUT_MS / 1000} s`);
console.log("audit", audit.audit_id, "score", audit.score, "flags", audit.flags);
// 5. Reset, in the sandbox only. The endpoint does not exist for a live key.
if (AUDIENCE === SANDBOX_AUDIENCE) {
await api<void>("POST", "/v2/sandbox/reset");
console.log("sandbox reset");
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
# Keep credentials on the server; never in a browser or mobile app.
import os
import sys
import time
import requests
BASE_URL = "https://api.brellium.com"
SANDBOX_AUDIENCE = "https://api.brellium.dev/sandbox"
AUDIENCE = os.environ.get("BRELLIUM_AUDIENCE", "https://api.brellium.dev")
FILE = sys.argv[1] if len(sys.argv) > 1 else "2026-09-23-session-note.pdf"
# In the sandbox, poll until `complete`; it takes about 30 s after the upload. Live
# processing takes longer.
TIMEOUT_S = 120 if AUDIENCE == SANDBOX_AUDIENCE else 3600
def mint_token() -> str:
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": AUDIENCE,
},
timeout=10,
)
res.raise_for_status()
return res.json()["access_token"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {mint_token()}"
def api(method: str, path: str, body=None, params=None):
res = session.request(
method, f"{BASE_URL}{path}", json=body, params=params, timeout=30
)
if res.status_code == 204:
return None
payload = res.json()
if not res.ok:
err = payload["error"]
raise RuntimeError(
f"{method} {path}: {res.status_code} {err['code']} {err['message']} "
f"(request_id {err.get('request_id')})"
)
return payload
# 1. Read the newest audit. Before your first upload, or after a reset, it is the
# sample audit.
audits = api("GET", "/v2/audits", params={"limit": 1})
print("newest audit:", audits["data"][0]["audit_id"] if audits["data"] else None)
# 2. Create one document. A new external_id per run keeps the request from replaying
# an earlier one.
created = api(
"POST",
"/v2/documents",
body={
"documents": [
{
"filename": "2026-09-23-session-note.pdf",
"content_type": "application/pdf",
"external_id": f"sandbox-test-{int(time.time())}",
}
],
},
)
doc = created["data"][0]
print("created", doc["document_id"], "in batch", doc["group_id"])
# 3. PUT the file with the headers given. A plain requests call, so no Authorization
# header is sent.
upload = doc["upload"]
if upload is None:
raise RuntimeError("no upload URL returned")
with open(FILE, "rb") as f:
put = requests.request(
upload["method"],
upload["url"],
headers=upload["headers"],
data=f,
timeout=300,
)
put.raise_for_status()
# 4. Poll the document and its audits until an audit covering it is complete.
deadline = time.monotonic() + TIMEOUT_S
last_status = ""
audit = None
while audit is None and time.monotonic() < deadline:
current = api("GET", f"/v2/documents/{doc['document_id']}")
if current["status"] != last_status:
last_status = current["status"]
print("document status:", last_status)
if last_status == "upload_expired":
raise RuntimeError("the upload expired")
found = api("GET", "/v2/audits", params={"documents": doc["document_id"]})
audit = next((a for a in found["data"] if a["status"] == "complete"), None)
if audit is None:
time.sleep(5)
if audit is None:
raise RuntimeError(f"no complete audit within {TIMEOUT_S} s")
print("audit", audit["audit_id"], "score", audit["score"], "flags", audit["flags"])
# 5. Reset, in the sandbox only. The endpoint does not exist for a live key.
if AUDIENCE == SANDBOX_AUDIENCE:
api("POST", "/v2/sandbox/reset")
print("sandbox reset")
Reset the sandbox
POST /v2/sandbox/reset with no body deletes everything in your organization's sandbox, uploaded files included, and returns 204. The next request seeds the sample data again. Reset for a known starting point, or when a create returns 409 SANDBOX_LIMIT (5,000 records per resource). The sandbox deletes nothing on a schedule.
curl "https://api.brellium.com/v2/sandbox/reset" \
--request POST \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--write-out "HTTP %{http_code}\n"
const res = await fetch("https://api.brellium.com/v2/sandbox/reset", {
method: "POST",
headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` },
});
if (!res.ok)
throw new Error(
`HTTP ${res.status}: ${JSON.stringify((await res.json()).error)}`,
);
console.log(`HTTP ${res.status}`);
Switch to live
Only the key and the audience change. Create a second key on API keys with Production selected under Environment, because a sandbox key can't become live. Point the same variables at it and run the same script with a real file.
export BRELLIUM_CLIENT_ID=your-live-client-id
read -s BRELLIUM_CLIENT_SECRET && export BRELLIUM_CLIENT_SECRET
export BRELLIUM_AUDIENCE=https://api.brellium.dev
| Sandbox | Live | |
|---|---|---|
| Processing | A simulated review: a document is complete 30 seconds after todo, then gets the sample audit (one per batch with "grouping": "group") |
A real review with no fixed duration; Brellium decides which documents get an audit |
| Audits | The sample audit's responses, the same for every upload | Responses from your file, with protected health information (PHI) |
| Zip uploads | Accepted, marked extracted, never unpacked |
Unpacked into one document per supported file |
POST /v2/users |
Returns an invitation; nobody is emailed |
Sends a real invitation email |
POST /v2/sandbox/reset |
Empties the sandbox | Does not exist (404 NOT_FOUND) |
Your organization can send a burst of 100 requests, then 5 per second, shared by every key; the sandbox has a separate budget with the same limits.
Common mistakes
| Mistake | What you see | Fix |
|---|---|---|
Reusing the same external_id on every run |
The same document_id comes back, with upload: null once its file has arrived, and no new audit |
Send a new external_id per run, or none in tests. |
Leaving out content_type |
400 VALIDATION_ERROR with path: ["documents", 0, "content_type"] |
Send content_type on every document, and PUT with the Content-Type from upload.headers. |
Expecting complete on the first read after the upload |
The first read returns todo |
Reads return complete 30 seconds after that. Poll until complete. |
Next
Test with a sandbox key
Call the same API against sample data with a sandbox key, then reset the sandbox.
Upload documents
Create documents, upload each file, and follow them through processing, expiry and zips.
List a document's audits
Filter audits by the documents, external_id, status and set_id parameters.