Migrate document uploads from v1
Every v1 document call except POST /v1/ehr-ids has a v2 replacement, but no upload ports line for line: v2 never takes the file in the API request. You create the documents with a JSON request, then PUT each file to the upload URL the response gives you. For the general rules of v2 uploads, see Upload documents; for the other resources, see Migrate from v1.
| v1 call | v2 replacement |
|---|---|
POST /v1/documents (multipart, one file) |
POST /v2/documents with one document, then PUT the file |
POST /v1/documents-multiple (multipart, several files that belong together) |
POST /v2/documents with one item per file and "grouping": "group", then PUT each file |
POST /v1/documents/upload-url (JSON, one upload URL) |
POST /v2/documents with one document |
GET /v1/documents/:document_id |
GET /v2/documents/{document_id} |
| (none) | GET /v2/documents and PATCH /v2/documents/{document_id} |
POST /v1/ehr-ids |
No v2 equivalent; contact Brellium support before you stop calling it on v1 |
Migration checklist
Copy this list into a ticket. Each item is one change to the code that calls the v1 document endpoints.
- Send requests to
https://api.brellium.com/v2/...(v1 stays at/v1/...), with a v2 access token (Authenticate with OAuth2 client credentials). Mint one token per key and reuse it until it expires (Mint one access token and reuse it).POST /v2/oauth/tokenallows a burst of 20 requests perclient_id, then one every 10 seconds; the 21st returns429withRetry-After. - Stop sending files to the API. v1 takes the file as
multipart/form-data. v2 takes a JSON request that describes the files (POST /v2/documents) and returns one upload URL per file. You thenPUTeach file's bytes to its URL. A multipart request to v2 fails with400 INVALID_CONTENT_TYPE. - Send
content_typeon every document, for exampleapplication/pdf. v1 reads the type from the file name's extension; v2 ignores the extension. - Send extra form fields, such as
ehr_source=sample-ehr, as a JSON object inexternal_metadataon each document. v1 stores every form field other than the file in the document's metadata. - Stop sending
doc_type. v2 sets it todefaulton every document and ignores any value you send. - List several files in one
POST /v2/documents(1 to 100 documents). Add"grouping": "group"when they belong together, asPOST /v1/documents-multipledoes; leave it out to have Brellium review each file on its own. - After the
PUT, there is nothing to confirm. The document moves frompending_uploadtotodowithin seconds, and Brellium then processes it. To see itsstatus, fetch the document, or the whole batch with?group_id=. - Each upload URL expires 15 minutes after the request (
upload.expires_at). Send thePUTbefore then, with exactly the headers inupload.headers. v1 upload URLs last 1 hour and accept anyContent-Type. - If any file of a batch is still missing 24 hours after the batch was created, the whole batch expires: its
pending_uploaddocuments becomeupload_expiredand itsexternal_idvalues are released, so you can send the batch again. The resend creates a new batch, and you upload every file again. - Set
external_idon every document to make retries safe. v1 creates new documents on every retry. In v2, a request whose documents all carry theexternal_idvalues of one earlier batch returns that batch's documents instead of creating new ones. Documents whose files have not arrived come back with new upload URLs. - Retrieve a document with
GET /v2/documents/{document_id}instead ofGET /v1/documents/:document_id. Your data is inexternal_metadata(v1 calls itmetadata). The fieldsorg_id,handler,audit_createdandneeds_auditare gone. To find a document's audits, useGET /v2/audits?documents=<document_id>. - Handle the v2 error format. Every error response is one JSON envelope:
{ "error": { "type", "code", "message", "param"?, "details"?, "request_id" } }. Branch ontypeandcode, which are stable;messageanddetailscan change.request_idequals thex-request-idresponse header.
Upload one file
In v2, POST /v1/documents becomes two requests: create the document with POST /v2/documents, then PUT the file. v1 takes one file in the multipart part file and stores it before it answers.
Request
The v1 request:
curl --request POST "https://<your v1 host>/v1/documents" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--form "file=@2026-09-23-session-note.pdf" \
--form "ehr_source=sample-ehr"
v2: create the document
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" },
},
],
}),
});
if (!res.ok) throw new Error(JSON.stringify((await res.json()).error));
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const documentUploadList = 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(documentUploadList);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
document_upload_list = 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(document_upload_list.to_json())
v2: upload the file
PUT the raw bytes to upload.url 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 res = await fetch(process.env.UPLOAD_URL!, {
method: "PUT",
headers: { "Content-Type": "application/pdf" },
body: await readFile("2026-09-23-session-note.pdf"),
});
console.log(`HTTP ${res.status}`);
A 200 with an empty body means the file arrived.
| v1 request | v2 request | Notes |
|---|---|---|
Content-Type: multipart/form-data |
Content-Type: application/json |
Any other content type, or none, returns 400 INVALID_CONTENT_TYPE. |
file part (the bytes) |
the PUT body to upload.url |
The bytes never pass through the API. |
The file part's own file name |
documents[].filename |
You send it yourself, 1 to 255 characters with no /, \ or control characters, and v2 keeps it exactly as sent. |
The file name's extension (.csv, .docx, .json, .pdf, .txt, .yaml, .yml) |
documents[].content_type |
Required. v2 ignores the extension and accepts the same file types, plus zip (File types). |
Every other form field, such as ehr_source |
a field in documents[].external_metadata |
v1 stores each form field in the document's metadata, as a string. v2 takes any JSON object and returns it unchanged. |
| (none) | documents[].external_id |
Optional. Your own id, up to 255 characters, which makes a retry return the same document. |
| (none) | grouping |
Optional, default separate. It matters with more than one document, and for a zip, where it applies to the documents created from its files. |
| 10 MB file size limit | (none in the API) | The API does not see the file. A zip can be up to 5 GB. |
Response
v1 returns 201 with the id:
{
"document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b"
}
v2 returns 201 with the list envelope; data[0] is the document and how to upload its file:
{
"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
}
| v1 response | v2 response | Notes |
|---|---|---|
document_id |
data[0].document_id |
Same kind of id. The document is inside the list envelope (object, data, has_more); has_more is always false here. |
| (none) | data[0].upload.url |
Where to PUT the file. |
| (none) | data[0].upload.method |
Always PUT. |
| (none) | data[0].upload.headers |
Send exactly these headers with the PUT. |
| (none) | data[0].upload.expires_at |
15 minutes after the request. |
| (none) | every other field of data[0] |
The rest of the document, as GET /v2/documents/{document_id} returns it (Retrieve a document). |
Behavior changes that break a direct port
- A
201no longer means Brellium has the file. v1 stores the file before it answers, and the document starts attodo. A v2 document starts atpending_upload, and nothing happens until yourPUTsucceeds. - The
PUTcan fail on its own. It returns a403in XML from the storage service when the URL has expired or theContent-Typediffers fromupload.headers. To get a new URL, send the same request again with the sameexternal_id. - Retry with
external_idset. After a500, a resend with the sameexternal_idreturns the same document instead of a new one (Safe retries). In v1, your retry creates a second document. - Send the JSON types you want back in
external_metadata. v1 receives every extra form field as text, sovisit_number=3arrives as"3".
Upload files that belong together
In v2, POST /v1/documents-multiple becomes one POST /v2/documents that lists every file and sets "grouping": "group", then a PUT of each file. v1 takes several files in the multipart part files and marks them as belonging together; "grouping": "group" marks the v2 documents the same way. In both versions, whether Brellium reviews them as one audit depends on your organization's setup.
Request
The v1 request:
curl --request POST "https://<your v1 host>/v1/documents-multiple" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--form "files=@2026-09-23-session-note.pdf" \
--form "files=@2026-09-23-data-sheet.csv" \
--form "ehr_source=sample-ehr"
v2: create the documents
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" }
},
{
"filename": "2026-09-23-data-sheet.csv",
"content_type": "text/csv",
"external_id": "EHR-NOTE-48213-ATT-1",
"external_metadata": { "ehr_source": "sample-ehr" }
}
],
"grouping": "group"
}'
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" },
},
{
filename: "2026-09-23-data-sheet.csv",
content_type: "text/csv",
external_id: "EHR-NOTE-48213-ATT-1",
external_metadata: { ehr_source: "sample-ehr" },
},
],
grouping: "group",
}),
});
if (!res.ok) throw new Error(JSON.stringify((await res.json()).error));
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const documentUploadList = 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" },
},
{
filename: "2026-09-23-data-sheet.csv",
content_type: "text/csv",
external_id: "EHR-NOTE-48213-ATT-1",
external_metadata: { ehr_source: "sample-ehr" },
},
],
grouping: "group",
});
console.log(documentUploadList);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
document_upload_list = 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"},
},
{
"filename": "2026-09-23-data-sheet.csv",
"content_type": "text/csv",
"external_id": "EHR-NOTE-48213-ATT-1",
"external_metadata": {"ehr_source": "sample-ehr"},
},
],
grouping="group",
)
print(document_upload_list.to_json())
v2: upload each file
PUT each file to its own upload.url, in any order, with its own upload.headers. For code that uploads both files, see Upload a note and its attachments.
| v1 request | v2 request | Notes |
|---|---|---|
Content-Type: multipart/form-data |
Content-Type: application/json |
|
One files part per file, no count limit, 10 MB each |
One item in documents per file, 1 to 100 |
With "grouping": "group", a batch holds at most 100 files, listed in the request or sent in one zip (Zips). A zip sent with "grouping": "group" that holds more supported files ends as extraction_failed with ARCHIVE_GROUP_TOO_LARGE. Several requests make separate batches, and Brellium does not review one batch with another. |
| Each part's own file name | documents[].filename |
|
| Each part's extension | documents[].content_type |
Required on each document. |
| Every other form field | documents[].external_metadata |
v1 copies the same fields onto every file. In v2 each document has its own object, so repeat the fields on each document that needs them. |
| Always marked as belonging together | "grouping": "group" |
Required to keep the v1 behavior. With the default, separate, Brellium reviews each document on its own. |
| (none) | documents[].external_id |
Optional, one per file, unique in the request. |
Response
v1 returns 201 with the ids, in the order of the files:
{
"document_ids": [
"0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"5d4c3b2a-1908-4f7e-8d6c-5b4a39281706"
]
}
v2 returns 201 with one document per file. This sample is trimmed to the keys that differ between the two documents:
{
"object": "list",
"data": [
{
"document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"filename": "2026-09-23-session-note.pdf",
"status": "pending_upload",
"group_id": "9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b",
"external_id": "EHR-NOTE-48213",
"metadata": {
"related_doc_ids": [
"0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"5d4c3b2a-1908-4f7e-8d6c-5b4a39281706"
],
"is_leading_doc": true
},
"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"
}
/* ... the other document fields */
},
{
"document_id": "5d4c3b2a-1908-4f7e-8d6c-5b4a39281706",
"filename": "2026-09-23-data-sheet.csv",
"status": "pending_upload",
"group_id": "9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b",
"external_id": "EHR-NOTE-48213-ATT-1",
"metadata": {
"related_doc_ids": [
"0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"5d4c3b2a-1908-4f7e-8d6c-5b4a39281706"
],
"is_leading_doc": false
},
"upload": {
"url": "https://uploads.example.com/5d4c3b2a-1908-4f7e-8d6c-5b4a39281706?signature=...",
"method": "PUT",
"headers": { "Content-Type": "text/csv" },
"expires_at": "2026-09-24T02:46:08.212Z"
}
/* ... the other document fields */
}
],
"has_more": false
}
| v1 response | v2 response | Notes |
|---|---|---|
document_ids[] |
data[].document_id |
Same order as the files you sent, in both versions. |
| (none) | data[].group_id |
One id shared by every document of the request. List them all with GET /v2/documents?group_id=. |
| (none) | data[].metadata.related_doc_ids, data[].metadata.is_leading_doc |
Read-only. Each document's related_doc_ids lists every document of the batch, and the first document has is_leading_doc set to true. |
| (none) | data[].upload |
One upload URL per document. |
| (none) | every other field of data[] |
See the field table in Retrieve a document. |
Behavior changes that break a direct port
- Upload every file. v1 stores all files in one request; in v2 each
PUTis separate, and a batch with a missing file expires. - One
POST /v2/documentsrequest creates one batch: its documents share agroup_id, and nothing can be added to it later. Send every file that belongs together in one request. - If any file of a batch is still missing 24 hours after the batch was created, the whole batch expires: its
pending_uploaddocuments becomeupload_expiredand itsexternal_idvalues are released, so you can send the batch again. A document whose file did arrive gets no audit or coding evaluation unless it already has one. The resend creates a new batch, so upload every file again, including the ones that arrived (Incomplete batches expire after 24 hours). - Retry with
external_idvalues that all exist in one earlier batch. Resending only the documents whose files are missing also works. A request that mixes existing values with new ones, adds a document without anexternal_id, or carries values from two batches returns409 EXTERNAL_ID_CONFLICTand creates nothing.
Get an upload URL
POST /v2/documents replaces POST /v1/documents/upload-url, the v1 call closest to v2: both take a JSON request and return an upload URL. v2 adds a required content_type, returns the full document, and can create up to 100 documents in one request.
Request
The v1 request:
curl --request POST "https://<your v1 host>/v1/documents/upload-url" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
--header "Content-Type: application/json" \
--data '{
"filename": "2026-09-23-session-note.pdf",
"doc_type": "session_note",
"metadata": { "ehr_source": "sample-ehr" }
}'
The v2 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-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" },
},
],
}),
});
if (!res.ok) throw new Error(JSON.stringify((await res.json()).error));
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const documentUploadList = 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(documentUploadList);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
document_upload_list = 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(document_upload_list.to_json())
Then PUT the file to upload.url, as in Upload one file.
| v1 request | v2 request | Notes |
|---|---|---|
filename |
documents[].filename |
v1 trims spaces and allows 255 bytes; v2 keeps the name exactly as sent and allows 255 characters. Both refuse /, \ and control characters. |
The extension of filename |
documents[].content_type |
Required. v1 refuses a name without a supported extension; v2 accepts any name and takes the type from content_type alone. |
doc_type |
dropped | v2 sets doc_type to default on every document and ignores any doc_type you send. |
doc_type: "treatment_plan" with an Idempotency-Key header |
no v2 equivalent | v1 handles treatment plan uploads differently. Contact Brellium support before you move treatment plan uploads. |
metadata (JSON object, up to 64 KB) |
documents[].external_metadata |
Any JSON object, returned unchanged. v2 also has a metadata field, but it is Brellium's and you cannot write it. |
| (none) | documents[].external_id |
Optional. It makes a retry return the same document, with a new upload URL while its file has not arrived. |
| (none) | grouping |
Optional, default separate. |
Response
v1 returns 201 with the id and the upload URL:
{
"document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"presigned_url": "https://uploads.example.com/...?signature=..."
}
The v2 response is the same as in Upload one file.
| v1 response | v2 response | Notes |
|---|---|---|
document_id |
data[0].document_id |
|
presigned_url |
data[0].upload.url |
Expires 15 minutes after the request, at upload.expires_at; a v1 URL lasts 1 hour. |
| (none) | data[0].upload.headers |
v1 accepts the PUT with any Content-Type. The v2 upload URL accepts it only with these headers. |
| (none) | data[0].upload.method, data[0].upload.expires_at |
|
| (none) | every other field of data[0] |
See the field table in Retrieve a document. |
Behavior changes that break a direct port
- Send the
PUTwith theContent-Typefromupload.headers. For a YAML file, that isapplication/yaml, even when the request saidtext/yaml. - Each upload URL expires 15 minutes after the request (
upload.expires_at); a v1 URL lasts 1 hour. For a new URL while the file has not arrived, send the same request again with the sameexternal_id. Withoutexternal_idthere is no way to get a new URL for the same document. - Neither version has a confirm call. In both, the document moves from
pending_uploadtotodoonce the file arrives. v2 adds an expiry: a batch with a file that never arrives expires after 24 hours (upload_expired).
Retrieve a document
GET /v2/documents/{document_id} replaces GET /v1/documents/:document_id. The path keeps its shape. In the response, your metadata becomes external_metadata, and v2 adds fields.
Request
The v1 request:
curl "https://<your v1 host>/v1/documents/0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
The v2 request:
curl "https://api.brellium.com/v2/documents/0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b" \
--header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN"
const res = await fetch(
"https://api.brellium.com/v2/documents/0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
{ headers: { Authorization: `Bearer ${process.env.BRELLIUM_ACCESS_TOKEN}` } },
);
if (!res.ok) throw new Error(JSON.stringify((await res.json()).error));
console.log(await res.json());
import BrelliumClient from "@brellium/api-client";
const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });
const document = await client.documents.retrieve(
"0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
);
console.log(document);
import os
from brellium_api_client import BrelliumClient
client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])
document = client.documents.retrieve("0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b")
print(document.to_json())
Response
The v1 response:
{
"document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"org_id": "org_sample",
"filename": "2026-09-23-session-note.pdf",
"status": "complete",
"handler": "sample_handler",
"audit_created": true,
"needs_audit": false,
"metadata": { "ehr_source": "sample-ehr" }
}
The v2 response:
{
"document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
"object": "document",
"filename": "2026-09-23-session-note.pdf",
"doc_type": "default",
"status": "complete",
"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:35:40.000Z"
}
| v1 response | v2 response | Notes |
|---|---|---|
document_id |
document_id |
Unchanged. |
| (none) | object |
Always "document". |
org_id |
dropped | Every document the API returns belongs to your token's organization. |
filename |
filename |
The name you sent, or the uploaded part's name for a v1 multipart upload. |
| (none) | doc_type |
default for every document uploaded through v2. |
status |
status |
See Statuses. |
| (none) | group_id |
The batch, meaning the documents one POST /v2/documents request created. null for a document that reached Brellium another way. |
| (none) | client, provider, service_code, bcba, us_state |
Read-only; you cannot send them. Each is an empty string when not set. To store your own values, use external_metadata. |
| (none) | external_id |
Your own id, or null. It becomes null when the batch expires. |
metadata |
external_metadata |
Your data, as you sent it. {} when you sent none. |
| (none) | metadata |
What Brellium records while processing the document, read-only: related_doc_ids and is_leading_doc for the batch, and metadata.archive on a zip. The v1 metadata is now external_metadata. |
handler |
dropped | |
audit_created |
dropped | GET /v2/audits?documents=<document_id> returns the audits that cover the document (List a document's audits). |
needs_audit |
dropped | Brellium decides whether a document is audited. |
| (none) | upload_time, created_at, updated_at |
RFC 3339 in UTC. |
Behavior changes that break a direct port
- A document you just created or changed can take a few seconds to appear at
GET /v2/documents/{document_id}; until then you can get404or the earlier version. A create or update response is the current record, so use it instead of fetching the record again. - An id that is not a UUID returns
400 VALIDATION_ERROR.
New in v2: list and update documents
GET /v2/documentslists your organization's documents, newest first. Filter bygroup_id,status,doc_type,provider,client,created_afterandcreated_before, or repeatdocument_idfor a batch get; there is noexternal_idfilter. A query string over 2,048 bytes is rejected with a bare403and no JSON body, so about 41document_idvalues fit in one request; split a longer list across requests. For the pagination rules, see Page through and filter lists.PATCH /v2/documents/{document_id}changesexternal_idandexternal_metadataonly. An omitted field keeps its value. A newexternal_metadatareplaces the whole object, andexternal_id: nullclears the id. A newexternal_idthat another document in your organization already uses returns409 EXTERNAL_ID_CONFLICT.
Statuses
| v1 | v2 | Notes |
|---|---|---|
todo at creation (POST /v1/documents, POST /v1/documents-multiple) |
pending_upload, then todo |
Every document you create starts at pending_upload, because the file comes after the request. The documents a zip unpacks into first appear at todo. |
pending_upload, then todo (POST /v1/documents/upload-url) |
pending_upload, then todo |
Unchanged. |
| (none) | upload_expired |
The batch expired before this file arrived. This status is final; send the batch again to upload the file. |
| (none) | extracting, extracted, extraction_failed |
Zip documents only (Zips). |
complete, error and other processing values |
the same values | Treat complete as done and accept values you do not recognize (Statuses). |
Error changes
v1 answers with {"error": "..."}, {"msg": "..."} or plain text. Every v2 error is one JSON envelope, as in this 409 for an external_id conflict:
{
"error": {
"type": "conflict_error",
"code": "EXTERNAL_ID_CONFLICT",
"message": "Some external_id values in this request are already used by documents from another request. Retry a request with exactly the documents it was first sent with.",
"details": { "external_ids": ["EHR-NOTE-48213"] },
"request_id": "7f0b3c1e-2a4d-4b8e-9c11-5d6e7f8a9b0c"
}
}
| Case | v1 | v2 |
|---|---|---|
| Missing, invalid or expired token | 401, plain text Unauthorized |
401, authentication_error / UNAUTHORIZED |
| No file | 400 {"error": "File is required"}, or "At least one file is required" on POST /v1/documents-multiple |
400, invalid_request_error / VALIDATION_ERROR when documents is empty |
| Unsupported file type | 400 {"error": "File type not allowed"} on the multipart calls; 400 naming the supported extensions on the upload URL call |
400, invalid_request_error / VALIDATION_ERROR when content_type is missing or not supported; error.details names the field |
| File over 10 MB | 400 {"error": "File too large"} on the multipart calls |
The API does not check the file size; a zip over 5 GB ends as extraction_failed with ARCHIVE_TOO_LARGE in metadata.archive.error |
| Wrong multipart part name | 400 {"error": "Unexpected field"} |
Not applicable; a multipart body returns 400, invalid_request_error / INVALID_CONTENT_TYPE |
| Body is not JSON | 400 {"message": "Invalid JSON format"} on the upload URL call |
400, invalid_request_error / INVALID_CONTENT_TYPE without Content-Type: application/json; malformed JSON with that header returns 400, invalid_request_error / HTTP_ERROR |
Bad filename, doc_type or metadata |
400 {"error": "..."} on the upload URL call |
400, invalid_request_error / VALIDATION_ERROR for a bad filename, more than 100 documents, a repeated external_id or a zip next to other documents. v2 ignores doc_type and a metadata field; send your data in external_metadata |
| Organization not set up for uploads | 404 {"error": "Organization not found"} on POST /v1/documents; 500 {"error": "Organization storage is not configured"} on the upload URL call |
404, not_found_error / ORG_NOT_FOUND; contact Brellium support |
external_id already used by another request |
(none) | 409, conflict_error / EXTERNAL_ID_CONFLICT; error.details.external_ids lists the values |
| Document does not exist | 404 {"msg": "document not found"} |
404, not_found_error / NOT_FOUND |
| Too many requests | (none) | 429, rate_limit_error / RATE_LIMITED, with a Retry-After header |
| Server error | 500 {"error": "Unexpected error occurred"} |
500, api_error / INTERNAL_ERROR. Retry with external_id set on uploads. |
Upload URL expired, or PUT with the wrong Content-Type |
403 in XML from the storage service (upload URL only) |
403 in XML from the storage service, with no error envelope |
Every v2 error body carries request_id, which equals the x-request-id response header; send it to Brellium support when you report a problem. Branch on error.type and error.code, which are stable; message can change.
For every code and the retry rules, see Rate limits and errors.
Next
Upload documents
Create documents, upload each file, and follow them through processing, expiry and zips.
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.
Migrate from v1
Move an integration from the v1 API to v2, one resource at a time.