Upload a note and its attachments

Send Brellium a session note and the files that belong to it, such as a data sheet or a signed form, as one batch, then fetch the audit. For the rules behind each step, see Upload documents.

Prerequisites

  • An access token in $BRELLIUM_ACCESS_TOKEN, cached and reused (Authenticate with OAuth2 client credentials).
  • Two files on disk, here 2026-09-23-session-note.pdf and 2026-09-23-data-sheet.csv.
  • An organization that is set up for document uploads. If it is not, POST /v2/documents returns 404 ORG_NOT_FOUND; contact Brellium support.
  • With a live key, Brellium processes the files, and both the files and the audit contain protected health information (PHI). A sandbox key never reads the files and returns a sample audit.

Steps

Create the documents

Send both documents in one POST /v2/documents request. One request creates one batch: its documents share a group_id, and nothing can be added to it later. content_type is required and sets the file type.

grouping: "group" marks the batch's documents as belonging together, so Brellium can review them as one audit, depending on your organization's setup. With separate (the default), Brellium reviews each document on its own. Brellium decides whether the documents are audited.

Give each document an external_id so that a retry of the request returns the same documents. A retry with one external_id changed returns 409 EXTERNAL_ID_CONFLICT and creates nothing.

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"
      },
      {
        "filename": "2026-09-23-data-sheet.csv",
        "content_type": "text/csv",
        "external_id": "EHR-NOTE-48213-ATT-1"
      }
    ],
    "grouping": "group"
  }' \
  | tee batch.json \
  | jq '{
      group_id: .data[0].group_id,
      documents: [
        .data[]
        | {document_id, status, url: .upload.url, headers: .upload.headers}
      ]
    }'
import { writeFile } from "node:fs/promises";

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",
      },
      {
        filename: "2026-09-23-data-sheet.csv",
        content_type: "text/csv",
        external_id: "EHR-NOTE-48213-ATT-1",
      },
    ],
    grouping: "group",
  }),
});
const batch = await res.json();
if (!res.ok) throw new Error(`HTTP ${res.status}: ${JSON.stringify(batch.error)}`);
await writeFile("batch.json", JSON.stringify(batch));

type Created = {
  document_id: string;
  status: string;
  upload: { url: string; headers: Record<string, string> } | null;
};
console.log({
  group_id: batch.data[0].group_id,
  documents: (batch.data as Created[]).map(({ document_id, status, upload }) => ({
    document_id,
    status,
    url: upload?.url,
    headers: upload?.headers,
  })),
});
import { writeFile } from "node:fs/promises";
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const batch = await client.documents.create({
  documents: [
    {
      filename: "2026-09-23-session-note.pdf",
      content_type: "application/pdf",
      external_id: "EHR-NOTE-48213",
    },
    {
      filename: "2026-09-23-data-sheet.csv",
      content_type: "text/csv",
      external_id: "EHR-NOTE-48213-ATT-1",
    },
  ],
  grouping: "group",
});
await writeFile("batch.json", JSON.stringify(batch));

console.log({
  group_id: batch.data[0].group_id,
  documents: batch.data.map(({ document_id, status, upload }) => ({
    document_id,
    status,
    url: upload?.url,
    headers: upload?.headers,
  })),
});
import json
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

batch = client.documents.create(
    documents=[
        {
            "filename": "2026-09-23-session-note.pdf",
            "content_type": "application/pdf",
            "external_id": "EHR-NOTE-48213",
        },
        {
            "filename": "2026-09-23-data-sheet.csv",
            "content_type": "text/csv",
            "external_id": "EHR-NOTE-48213-ATT-1",
        },
    ],
    grouping="group",
)
with open("batch.json", "w") as f:
    f.write(batch.to_json())

print(
    json.dumps(
        {
            "group_id": batch.data[0].group_id,
            "documents": [
                {
                    "document_id": d.document_id,
                    "status": d.status,
                    "url": d.upload.url if d.upload else None,
                    "headers": d.upload.headers if d.upload else None,
                }
                for d in batch.data
            ],
        },
        indent=2,
    )
)

The response is 201 with the documents in request order (Create documents). Both documents are pending_upload and share one group_id, and each has its own upload with url, method, headers and expires_at. Each upload URL expires 15 minutes after the request (upload.expires_at).

Each sample saves the full response to batch.json for the next steps and prints a summary like the one below. Your ids and URLs differ.

{
  // the batch; list it with ?group_id=
  "group_id": "9c3f1a7e-5b2d-4e8f-a1c6-2d7b8e9f0a1b",
  "documents": [
    {
      "document_id": "0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b",
      "status": "pending_upload",
      "url": "https://uploads.example.com/0f8e7d6c-5b4a-4938-8271-605f4e3d2c1b?signature=...",
      // send exactly these with the PUT
      "headers": { "Content-Type": "application/pdf" }
    },
    {
      "document_id": "5d4c3b2a-1908-4f7e-8d6c-5b4a39281706",
      "status": "pending_upload",
      "url": "https://uploads.example.com/5d4c3b2a-1908-4f7e-8d6c-5b4a39281706?signature=...",
      "headers": { "Content-Type": "text/csv" }
    }
  ]
}
Upload each file

PUT each file to its upload.url with the headers from upload.headers, in any order. Send no Authorization header, because the signature in the URL authorizes the request. There is nothing to call afterward.

NOTE_URL=$(jq -r '.data[0].upload.url' batch.json)
SHEET_URL=$(jq -r '.data[1].upload.url' batch.json)

curl --request PUT "$NOTE_URL" \
  --header "Content-Type: application/pdf" \
  --upload-file 2026-09-23-session-note.pdf \
  --write-out "HTTP %{http_code}\n"

curl --request PUT "$SHEET_URL" \
  --header "Content-Type: text/csv" \
  --upload-file 2026-09-23-data-sheet.csv \
  --write-out "HTTP %{http_code}\n"
import { readFile } from "node:fs/promises";

const batch = JSON.parse(await readFile("batch.json", "utf8"));
const noteUrl: string = batch.data[0].upload.url;
const sheetUrl: string = batch.data[1].upload.url;

// No Authorization header: each URL carries its own signature
const note = await fetch(noteUrl, {
  method: "PUT",
  headers: { "Content-Type": "application/pdf" },
  body: await readFile("2026-09-23-session-note.pdf"),
});
console.log(`HTTP ${note.status}`);

const sheet = await fetch(sheetUrl, {
  method: "PUT",
  headers: { "Content-Type": "text/csv" },
  body: await readFile("2026-09-23-data-sheet.csv"),
});
console.log(`HTTP ${sheet.status}`);

Each PUT returns 200, and within seconds the document moves to todo.

Poll the batch until it is complete

List the batch by group_id. A document is done when its status is complete. upload_expired (the batch expired before the file arrived) and error (processing failed) are final; for any other value, keep polling.

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. To try again, send the same request with the same external_id values and upload every file.

GROUP_ID=$(jq -r '.data[0].group_id' batch.json)

curl "https://api.brellium.com/v2/documents?group_id=$GROUP_ID" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  | jq '.data[] | {document_id, filename, status}'
import { readFile } from "node:fs/promises";

const batch = JSON.parse(await readFile("batch.json", "utf8"));
const query = new URLSearchParams({ group_id: batch.data[0].group_id });

const res = await fetch(`https://api.brellium.com/v2/documents?${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)}`);

type Doc = { document_id: string; filename: string; status: string };
for (const { document_id, filename, status } of body.data as Doc[]) {
  console.log({ document_id, filename, status });
}
import { readFile } from "node:fs/promises";
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const batch = JSON.parse(await readFile("batch.json", "utf8"));
const documents = await client.documents.list({ group_id: batch.data[0].group_id });

for (const { document_id, filename, status } of documents.data) {
  console.log({ document_id, filename, status });
}
import json
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

with open("batch.json") as f:
    batch = json.load(f)
documents = client.documents.list(group_id=batch["data"][0]["group_id"])

for d in documents.data:
    print(
        {"document_id": d.document_id, "filename": d.filename, "status": d.status}
    )

Poll about every 30 seconds, because processing can take minutes. Lists can take a few seconds to include a new or changed document.

Fetch the audit

List the audits for the batch with the documents filter, repeated once per document id. Depending on your organization's setup, grouping: "group" gives one audit that lists both documents in documents[]. Otherwise each document that Brellium audits gets its own audit, so handle both cases.

NOTE_ID=$(jq -r '.data[0].document_id' batch.json)
SHEET_ID=$(jq -r '.data[1].document_id' batch.json)

curl "https://api.brellium.com/v2/audits?documents=$NOTE_ID&documents=$SHEET_ID" \
  --header "Authorization: Bearer $BRELLIUM_ACCESS_TOKEN" \
  | jq '.data[] | {audit_id, status, score, flags, documents}'
import { readFile } from "node:fs/promises";

const batch = JSON.parse(await readFile("batch.json", "utf8"));
const query = new URLSearchParams();
query.append("documents", batch.data[0].document_id);
query.append("documents", batch.data[1].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)}`);

type Audit = {
  audit_id: string;
  status: string;
  score: number | null;
  flags: string[];
  documents: string[];
};
for (const { audit_id, status, score, flags, documents } of body.data as Audit[]) {
  console.log({ audit_id, status, score, flags, documents });
}
import { readFile } from "node:fs/promises";
import BrelliumClient from "@brellium/api-client";

const client = new BrelliumClient({ oAuth2: process.env.BRELLIUM_ACCESS_TOKEN });

const batch = JSON.parse(await readFile("batch.json", "utf8"));
const { data } = await client.audits.list({
  documents: [batch.data[0].document_id, batch.data[1].document_id],
});
for (const { audit_id, status, score, flags, documents } of data) {
  console.log({ audit_id, status, score, flags, documents });
}
import json
import os

from brellium_api_client import BrelliumClient

client = BrelliumClient(o_auth2=os.environ["BRELLIUM_ACCESS_TOKEN"])

with open("batch.json") as f:
    batch = json.load(f)
audits = client.audits.list(
    documents=[batch["data"][0]["document_id"], batch["data"][1]["document_id"]],
)
for a in audits.data:
    print(
        {
            "audit_id": a.audit_id,
            "status": a.status,
            "score": a.score,
            "flags": a.flags,
            "documents": a.documents,
        }
    )

The list can be empty at first, because an audit can appear a little after its documents are complete. An audit is finished only when its own status is complete; read score, flags and responses only then. To fetch one audit in full, see Retrieve an audit by id.

The whole flow in code

Each script runs the four steps in order and prints each audit's audit_id, score, flags and number of documents. It stops with an error when a document ends in error or upload_expired.

// 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 token = process.env.BRELLIUM_ACCESS_TOKEN;
if (!token) throw new Error("BRELLIUM_ACCESS_TOKEN is not set");

type Upload = {
  url: string;
  method: "PUT";
  headers: Record<string, string>;
  expires_at: string;
};
type Document = {
  document_id: string;
  filename: 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 List<T> = { object: "list"; data: T[]; has_more: boolean };

async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  const res = await fetch(`${BASE_URL}${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });
  const body = await res.json();
  if (!res.ok) {
    throw new Error(
      `${res.status} ${body.error.code}: ${body.error.message} (request_id ` +
        `${body.error.request_id})`,
    );
  }
  return body as T;
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const files = [
  {
    path: "2026-09-23-session-note.pdf",
    content_type: "application/pdf",
    external_id: "EHR-NOTE-48213",
  },
  {
    path: "2026-09-23-data-sheet.csv",
    content_type: "text/csv",
    external_id: "EHR-NOTE-48213-ATT-1",
  },
];

// 1. Create the batch. Sending this again with the same external_ids returns
//    the same documents, with new upload URLs for files not yet uploaded.
const created = await api<List<Document>>("/v2/documents", {
  method: "POST",
  body: JSON.stringify({
    documents: files.map((f) => ({
      filename: f.path,
      content_type: f.content_type,
      external_id: f.external_id,
    })),
    grouping: "group",
  }),
});
const groupId = created.data[0]?.group_id;

// 2. Upload each file. upload is null when the file already arrived.
for (const [i, doc] of created.data.entries()) {
  if (!doc.upload) continue;
  const put = await fetch(doc.upload.url, {
    method: "PUT",
    headers: doc.upload.headers, // never send Authorization here
    body: await readFile(files[i]!.path),
  });
  if (!put.ok)
    throw new Error(`Upload of ${doc.filename} failed: HTTP ${put.status}`);
}

// 3. Poll the batch until every document is finished. Give up after an hour;
//    the group_id in the error lets you check the batch later.
const deadline = Date.now() + 3_600_000;
const nextPoll = async () => {
  if (Date.now() > deadline)
    throw new Error(`Still waiting after an hour; check batch ${groupId} later`);
  await sleep(30_000);
};
const FINAL = new Set(["complete", "error", "upload_expired"]);
let docs: Document[] = [];
do {
  await nextPoll();
  docs = (await api<List<Document>>(`/v2/documents?group_id=${groupId}`)).data;
  console.log(docs.map((d) => `${d.filename}: ${d.status}`).join(", "));
} while (docs.length < files.length || docs.some((d) => !FINAL.has(d.status)));

const failed = docs.filter((d) => d.status !== "complete");
if (failed.length > 0)
  throw new Error(
    `Not processed: ${failed.map((d) => `${d.filename} (${d.status})`).join(", ")}`,
  );

// 4. Poll for the audits that cover the batch until each is complete.
const params = new URLSearchParams();
for (const d of docs) params.append("documents", d.document_id);
let audits: Audit[] = [];
do {
  await nextPoll();
  audits = (await api<List<Audit>>(`/v2/audits?${params}`)).data;
} while (audits.length === 0 || audits.some((a) => a.status !== "complete"));

for (const a of audits) {
  console.log(
    `${a.audit_id} score ${a.score} flags [${a.flags.join(", ")}] documents ` +
      `${a.documents.length}`,
  );
}
# Keep credentials on the server; never in a browser or mobile app.
import os
import time

import requests

BASE_URL = "https://api.brellium.com"
TOKEN = os.environ["BRELLIUM_ACCESS_TOKEN"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {TOKEN}"


def api(method: str, path: str, **kwargs) -> dict:
    res = session.request(method, f"{BASE_URL}{path}", timeout=30, **kwargs)
    if not res.ok:
        err = res.json()["error"]
        raise RuntimeError(
            f"{res.status_code} {err['code']}: {err['message']} (request_id "
            f"{err.get('request_id')})"
        )
    return res.json()


files = [
    {
        "path": "2026-09-23-session-note.pdf",
        "content_type": "application/pdf",
        "external_id": "EHR-NOTE-48213",
    },
    {
        "path": "2026-09-23-data-sheet.csv",
        "content_type": "text/csv",
        "external_id": "EHR-NOTE-48213-ATT-1",
    },
]

# 1. Create the batch. Sending this again with the same external_ids returns
#    the same documents, with new upload URLs for files not yet uploaded.
created = api(
    "POST",
    "/v2/documents",
    json={
        "documents": [
            {
                "filename": f["path"],
                "content_type": f["content_type"],
                "external_id": f["external_id"],
            }
            for f in files
        ],
        "grouping": "group",
    },
)
group_id = created["data"][0]["group_id"]

# 2. Upload each file. upload is None when the file already arrived.
#    Use plain requests.put, not the session: never send Authorization here.
for f, doc in zip(files, created["data"]):
    upload = doc["upload"]
    if upload is None:
        continue
    with open(f["path"], "rb") as body:
        put = requests.put(
            upload["url"], data=body, headers=upload["headers"], timeout=300
        )
    put.raise_for_status()

# 3. Poll the batch until every document is finished. Give up after an hour;
#    the group_id in the error lets you check the batch later.
deadline = time.monotonic() + 3600


def next_poll() -> None:
    if time.monotonic() > deadline:
        raise RuntimeError(
            f"Still waiting after an hour; check batch {group_id} later"
        )
    time.sleep(30)


FINAL = {"complete", "error", "upload_expired"}
while True:
    next_poll()
    docs = api("GET", "/v2/documents", params={"group_id": group_id})["data"]
    print(", ".join(f"{d['filename']}: {d['status']}" for d in docs))
    if len(docs) == len(files) and all(d["status"] in FINAL for d in docs):
        break

failed = [d for d in docs if d["status"] != "complete"]
if failed:
    raise RuntimeError(
        "Not processed: "
        + ", ".join(f"{d['filename']} ({d['status']})" for d in failed)
    )

# 4. Poll for the audits that cover the batch until each is complete.
#    A list value repeats the parameter: ?documents=<id>&documents=<id>.
document_ids = [d["document_id"] for d in docs]
while True:
    next_poll()
    audits = api("GET", "/v2/audits", params={"documents": document_ids})["data"]
    if audits and all(a["status"] == "complete" for a in audits):
        break

for a in audits:
    print(
        f"{a['audit_id']} score {a['score']} flags {a['flags']} documents "
        f"{len(a['documents'])}"
    )

Both scripts give up after an hour of polling and name the group_id in the error, so you can check the batch later.

Common mistakes

Mistake What you see Fix
Leaving out content_type 400 VALIDATION_ERROR, path: ["documents", 0, "content_type"] Send a supported content_type on every document; the API ignores the file name's extension.
Sending the body without Content-Type: application/json 400 INVALID_CONTENT_TYPE Add the header to every POST and PATCH.
A PUT whose Content-Type differs from upload.headers, or sent after upload.expires_at 403 from the upload URL, in XML Send upload.headers exactly. For a document created as text/yaml, they carry application/yaml. For a new URL, resend the same POST with the same external_id values.

Next