Upload documents
Send clinical documents to Brellium for review in two steps: POST /v2/documents creates the documents and returns an upload URL for each, then you PUT each file to its upload URL. There is no confirm call: Brellium processes each document once its file arrives. For a walkthrough with code, see Upload a note and its attachments.
The upload sequence
Treat complete as done, and handle status values you do not recognize. For every value, see Statuses.
Upload one document
Set $BRELLIUM_ACCESS_TOKEN first (Make your first request).
Create the document
Send one item in documents with filename and content_type. The example also sets two optional fields: external_id, which makes a retry safe, and external_metadata, which comes back unchanged.
curl --request POST "https://api.brellium.com/v2/documents" \
--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": "EHR-NOTE-48213",
"external_metadata": { "ehr_source": "sample-ehr" }
}
]
}'
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: "EHR-NOTE-48213",
external_metadata: { ehr_source: "sample-ehr" },
},
],
}),
});
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: "EHR-NOTE-48213",
external_metadata: { ehr_source: "sample-ehr" },
},
],
});
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": "EHR-NOTE-48213",
"external_metadata": {"ehr_source": "sample-ehr"},
},
],
)
print(documents.to_json())
The response is 201 with the documents in request order, each with an upload object:
{
"object": "list",
"data": [
{
"document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"object": "document",
"filename": "2026-09-23-session-note.pdf",
"doc_type": "default",
"status": "pending_upload",
"group_id": "9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b",
"client": "",
"provider": "",
"service_code": "",
"bcba": "",
"us_state": "",
"external_id": "EHR-NOTE-48213",
"external_metadata": { "ehr_source": "sample-ehr" },
"metadata": {
"related_doc_ids": ["0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b"],
"is_leading_doc": true
},
"upload_time": "2026-09-24T02:31:08.212Z",
"created_at": "2026-09-24T02:31:08.212Z",
"updated_at": "2026-09-24T02:31:08.212Z",
"upload": {
"url": "https://uploads.example.com/0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b?signature=...",
"method": "PUT",
"headers": { "Content-Type": "application/pdf" },
"expires_at": "2026-09-24T02:46:08.212Z"
}
}
],
"has_more": false
}
Store document_id and group_id. Brellium sets metadata, which is read-only. Its related_doc_ids lists the documents grouped with this one. Unless grouping is group, that list holds only the document itself.
Upload the file
Set UPLOAD_URL to upload.url from the response, then PUT the raw bytes to it with the headers in upload.headers. Send no Authorization header, because the signature in the URL authorizes the request.
curl --request PUT "$UPLOAD_URL" \
--header "Content-Type: application/pdf" \
--upload-file 2026-09-23-session-note.pdf \
--write-out "HTTP %{http_code}\n"
import { readFile } from "node:fs/promises";
const uploadUrl = process.env.UPLOAD_URL;
if (!uploadUrl) throw new Error("UPLOAD_URL is not set");
// No Authorization header: the signature in the URL is the credential.
const res = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: await readFile("2026-09-23-session-note.pdf"),
});
console.log(`HTTP ${res.status}`);
200 with an empty body means the file arrived. A 403 comes from the storage service and does not use the API's error envelope (Errors).
Wait for processing
Fetch the document, or the batch with ?group_id=, until status is complete:
curl \
"https://api.brellium.com/v2/documents?group_id=9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
| jq '.data[] | {document_id, status}'
const params = new URLSearchParams({
group_id: "9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b",
});
const res = await fetch(`https://api.brellium.com/v2/documents?${params}`, {
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)}`);
for (const { document_id, status } of body.data)
console.log({ document_id, status });
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const documents = await client.documents.list({
group_id: "9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b",
});
for (const { document_id, status } of documents.data)
console.log({ document_id, status });
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
documents = client.documents.list(group_id="9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b")
for document in documents.data:
print({"document_id": document.document_id, "status": document.status})
A document you just created or changed can take a few seconds to appear at GET /v2/documents/{document_id}; until then you can get 404 or the earlier version. Lists can take a few seconds to include a new or changed document. Polling about every 30 seconds is enough.
Find the audit
After the document is complete, Brellium can create an audit for it. GET /v2/audits?documents=<document_id> returns that audit, which lists the document in documents[]. For a walkthrough with code, see List a document's audits.
The request body
| Field | Required | What it does |
|---|---|---|
documents |
Yes | 1 to 100 documents, returned in the same order. |
grouping |
No | separate (the default) or group, explained in Batches and grouping. |
Each item in documents takes these fields:
| Field | Required | Rules |
|---|---|---|
filename |
Yes | 1 to 255 characters; no /, \ or control characters; any extension or none. Kept exactly as sent. |
content_type |
Yes | The file type, one of the values in File types. |
external_id |
No | Your own id, up to 255 characters, unique in your organization and in the request. It makes a retry safe; see Safe retries. |
external_metadata |
No | Any JSON object, stored and returned unchanged. Replace it later with PATCH /v2/documents/{document_id}. |
The API ignores any other field. Brellium decides whether each document is audited or gets a coding evaluation.
File types
content_type alone sets the file type, and the API ignores the extension in filename. In content_type, the API ignores case and parameters such as ; charset=utf-8.
| File type | Accepted content_type values |
Content-Type to send with the PUT |
|---|---|---|
application/pdf |
application/pdf |
|
| Word (DOCX) | application/vnd.openxmlformats-officedocument.wordprocessingml.document |
the same value |
| Plain text | text/plain |
text/plain |
| JSON | application/json |
application/json |
| CSV | text/csv |
text/csv |
| YAML | application/yaml, application/x-yaml, text/yaml |
application/yaml |
| Zip | application/zip, application/x-zip-compressed |
application/zip |
Any other content_type, such as image/png or the older .doc format, returns 400 VALIDATION_ERROR. Send the PUT with the Content-Type from upload.headers, even when the request used an alias such as text/yaml.
Batches and grouping
One POST /v2/documents request creates one batch: its documents share a group_id, and nothing can be added to it later.
grouping says how the documents in a batch relate. With separate (the default), Brellium reviews each document on its own. grouping: "group" marks the batch's documents as belonging together, such as a session note and its attachments, so Brellium can review them as one audit, depending on your organization's setup. Contact Brellium support before you rely on one audit per batch.
Upload each file to its own upload.url, in any order. For code that uploads a note and its attachment as one group, see Upload a note and its attachments.
Upload URL expiry
Each upload URL expires 15 minutes after the request (upload.expires_at). After that, the PUT fails with 403.
To get a new URL, send the request again with the same external_id values while the document is still pending_upload (Safe retries). Documents whose file has not arrived come back with a new upload, and the others come back with upload: null. Sending only the documents whose file is missing also works. Without external_id, a repeated request creates a new batch instead, and the old batch expires 24 hours after it was created.
Safe retries
Set external_id on every document so a retry after a timeout is safe. That includes the retries an SDK makes on its own (Clients and SDKs): without external_id values, a retried request can create a second batch.
external_id values in the request |
Result |
|---|---|
| All exist, all from one earlier batch | The API returns 201 with the matching documents in request order and creates nothing. It compares only external_id, so a resend does not change filename or grouping. |
| None exists | A new batch. |
| Anything else: new and existing values mixed, a document without one next to existing values, or values from more than one batch | The API returns 409 EXTERNAL_ID_CONFLICT and creates nothing. error.details.external_ids lists the values that already exist. |
Two copies of one request with external_id values, sent at the same time, both return the same batch.
Incomplete batches expire after 24 hours
If any file of a batch is still missing 24 hours after the batch was created, the whole batch expires: its pending_upload documents become upload_expired and its external_id values are released, so you can send the batch again. Every document in the batch then has external_id: null. A document whose file did arrive gets no audit or coding evaluation unless it already has one.
To recover, resend the whole batch with the same external_id values, including the documents whose files arrived. That creates a new batch with a new group_id, and you upload every file again.
Zips
A zip can carry more files than the 100 documents one request allows. Send it as one application/zip document, alone in its request. A zip next to any other document returns 400 VALIDATION_ERROR, and error.details carries the message a zip must be the only document in the request.
curl --request POST "https://api.brellium.com/v2/documents" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"documents": [
{
"filename": "2026-09-notes.zip",
"content_type": "application/zip"
}
],
"grouping": "separate"
}'
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-notes.zip", content_type: "application/zip" }],
grouping: "separate",
}),
});
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-notes.zip", content_type: "application/zip" }],
grouping: "separate",
});
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-notes.zip", "content_type": "application/zip"}
],
grouping="separate",
)
print(documents.to_json())
Upload the zip to its upload.url with Content-Type: application/zip. Each supported file in it becomes its own document:
- It has the zip's
group_idand follows the request'sgrouping. - It has the zip's
external_metadataandexternal_id: null. - It is named after the file inside the zip, without its folder, cut to 255 characters.
- It becomes
todoonce the whole zip is unpacked.
A file in the zip is supported when its name ends in .pdf, .docx, .txt, .json, .csv, .yaml or .yml. Brellium unpacks zips inside the zip one level deep. It skips files with any other extension, files in hidden folders, dotfiles, __MACOSX/ and empty files. The zip document itself is never audited.
The documents unpacked from a zip share its group_id. A list filtered by that group_id returns the zip document and every document unpacked from it. The zip document's status stays extracted, so leave it out when you wait for complete. Page with starting_after while has_more is true, because a zip can create up to 50,000 documents (Page through and filter lists).
Limits
Each limit counts the zip and the zips inside it together.
| Limit | Value |
|---|---|
| Size of the zip you upload | 5 GB |
| Size of every zip inside it, combined | 5 GB |
| Total unpacked size, inner zips included | 20 GB |
| Files and folders in the zip | 50,000 |
Supported files with grouping: "group" |
100 |
| Unpacked size of one file compared with its compressed size | 100 to 1 |
Refused zips
A refused zip is extraction_failed (Statuses), and its metadata.archive.error is one of these codes:
metadata.archive.error |
What it means | What to do |
|---|---|---|
ARCHIVE_TOO_LARGE |
The zip, its inner zips or its unpacked contents are over a size limit. | Split the files across several zips. |
ARCHIVE_RATIO_EXCEEDED |
A file unpacks to more than 100 times its compressed size. | Upload that file on its own, outside the zip. |
ARCHIVE_TOO_MANY_ENTRIES |
More than 50,000 files and folders. | Split the files across several zips. |
ARCHIVE_GROUP_TOO_LARGE |
grouping is group and the zip holds more than 100 supported files. |
Use fewer files per zip, or separate. |
ARCHIVE_NESTED |
A zip inside a zip inside your zip. | Flatten the zip to at most one inner level. |
ARCHIVE_INVALID |
Brellium can't read the zip: it is damaged or encrypted, uses an unsupported compression method, or holds a repeated path, an unsafe path or a file name with a control character. | Create the zip again with a standard tool, without a password. |
ARCHIVE_NO_SUPPORTED_FILES |
No file in the zip can become a document. | Check the file extensions against the supported list. |
ARCHIVE_RETRIES_EXHAUSTED |
Brellium could not unpack the zip. | Contact Brellium support with the zip's document_id. |
Statuses
status |
Meaning |
|---|---|
pending_upload |
Created, waiting for its file. |
todo |
The file arrived; waiting to be processed. |
complete |
Processing finished. |
error |
Processing failed. |
upload_expired |
The batch expired before this file arrived. |
extracting |
A zip whose files are being unpacked. |
extracted |
A zip whose files became documents; metadata.archive.entries counts them and metadata.archive.skipped counts the files skipped. |
extraction_failed |
A zip that was refused. Unpacking is all or nothing, so no documents were created; metadata.archive.error holds the reason. |
Other values can appear between todo and complete, and Brellium can add new ones.
Errors
Every error response is the JSON envelope described in Rate limits and errors.
| Status | code |
Cause |
|---|---|---|
400 |
VALIDATION_ERROR |
An empty documents array or more than 100 documents, a missing or unsupported content_type, a bad filename, a duplicate external_id in the request, or a zip next to other documents. error.details lists each problem with its path. |
404 |
ORG_NOT_FOUND |
Your organization is not set up for document uploads. Contact Brellium support. |
409 |
EXTERNAL_ID_CONFLICT |
Some external_id values are already used by documents from another request; see Safe retries. |
Send Content-Type: application/json with every request body to the API; any other content type, or none, returns 400 INVALID_CONTENT_TYPE.
A 403 from the upload URL is an XML response from the storage service and does not use this envelope. It means the URL has expired or the Content-Type header differs from the one in upload.headers. Fix the header, or get a new URL (Upload URL expiry), and upload again.
After the upload
PATCH /v2/documents/{document_id} changes only external_id and external_metadata. It returns 409 EXTERNAL_ID_CONFLICT when another document in your organization already uses the new external_id, and 404 NOT_FOUND when your organization has no document with this id.
For the list filters, see Page through and filter lists. For a document's audits, see List a document's audits.
With a sandbox key, the same requests run against simulated processing. For the simulated statuses and their timing, see Test with a sandbox key.
Next
Upload a note and its attachments
Upload a note and its attachments as one batch, poll the batch, then fetch the audit.
List a document's audits
Filter audits by the documents, external_id, status and set_id parameters.
Test with a sandbox key
Call the same API against sample data with a sandbox key, then reset the sandbox.