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/token allows a burst of 20 requests per client_id, then one every 10 seconds; the 21st returns 429 with Retry-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 then PUT each file's bytes to its URL. A multipart request to v2 fails with 400 INVALID_CONTENT_TYPE.
  • Send content_type on every document, for example application/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 in external_metadata on each document. v1 stores every form field other than the file in the document's metadata.
  • Stop sending doc_type. v2 sets it to default on 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, as POST /v1/documents-multiple does; leave it out to have Brellium review each file on its own.
  • After the PUT, there is nothing to confirm. The document moves from pending_upload to todo within seconds, and Brellium then processes it. To see its status, fetch the document, or the whole batch with ?group_id=.
  • Each upload URL expires 15 minutes after the request (upload.expires_at). Send the PUT before then, with exactly the headers in upload.headers. v1 upload URLs last 1 hour and accept any Content-Type.
  • 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. The resend creates a new batch, and you upload every file again.
  • Set external_id on every document to make retries safe. v1 creates new documents on every retry. In v2, a request whose documents all carry the external_id values 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 of GET /v1/documents/:document_id. Your data is in external_metadata (v1 calls it metadata). The fields org_id, handler, audit_created and needs_audit are gone. To find a document's audits, use GET /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 on type and code, which are stable; message and details can change. request_id equals the x-request-id response 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 201 no longer means Brellium has the file. v1 stores the file before it answers, and the document starts at todo. A v2 document starts at pending_upload, and nothing happens until your PUT succeeds.
  • The PUT can fail on its own. It returns a 403 in XML from the storage service when the URL has expired or the Content-Type differs from upload.headers. To get a new URL, send the same request again with the same external_id.
  • Retry with external_id set. After a 500, a resend with the same external_id returns 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, so visit_number=3 arrives 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 PUT is separate, and a batch with a missing file expires.
  • One POST /v2/documents request creates one batch: its documents share a group_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_upload documents become upload_expired and its external_id values 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_id values 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 an external_id, or carries values from two batches returns 409 EXTERNAL_ID_CONFLICT and 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 PUT with the Content-Type from upload.headers. For a YAML file, that is application/yaml, even when the request said text/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 same external_id. Without external_id there 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_upload to todo once 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 get 404 or 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/documents lists your organization's documents, newest first. Filter by group_id, status, doc_type, provider, client, created_after and created_before, or repeat document_id for a batch get; there is no external_id filter. A query string over 2,048 bytes is rejected with a bare 403 and no JSON body, so about 41 document_id values 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} changes external_id and external_metadata only. An omitted field keeps its value. A new external_metadata replaces the whole object, and external_id: null clears the id. A new external_id that another document in your organization already uses returns 409 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