Uploading a document

Attach a binary — a lab result PDF, a scanned referral, an imaging file — to a patient as a FHIR R4 DocumentReference, then read it back through a short-lived signed download URL. You will upload by two routes (multipart for a real file on disk, inline base64 for a small payload you already hold in memory), read the document, search a patient's documents, and soft-delete a mistake. One scope carries the flow: system/DocumentReference.cru — create, read, and update.

The binary is never stored in or returned from the resource body. On read, the document's bytes are served via a 30-minute signed URL on content[0].attachment.url; the resource itself only carries metadata. That split is the thing to internalize: you $upload bytes once, then every later read hands you a fresh, expiring URL to fetch them.

Audience

You integrate a lab, an imaging system, or a document pipeline that pushes files into a patient's chart. You read a Bundle without a viewer, you can build a multipart/form-data request or base64-encode a file, and you want a document from disk to a stored, retrievable DocumentReference.

You'll need

  • A bearer token from HuliPractice (Practice Settings → Integrations → API Keys), or a SMART Backend Services access token. See Bearer Tokens for provisioning and POST /auth/token for the token exchange.
  • The system/DocumentReference.cru scope on that token. It sits under the Clinical information card, which is BAA-gated — the clinic admin must attest to a Business Associate Agreement before a key carrying it can be minted. .cru grants upload + read + update; system/DocumentReference.rs alone grants read + search.
  • The id of the patient the document belongs to. Resolve it with a Patient search if you only hold a name.
  • A file to upload. Allowed types are PDF, JPEG, PNG, WEBP, and DICOM, up to 25 MB.
  • curl, or Node or Python if you prefer a language client.

End state

You hold a 201 Created whose body is the stored DocumentReference — patient as subject, a server-detected content[0].attachment.contentType, the file size and SHA-256 hash, and (on a later read) a signed url you can GET to download the bytes for the next 30 minutes.

Steps

1. Export the token and the patient id

export HULI_TOKEN="<paste your bearer token here>"
export PATIENT_ID="01965e2a-8c4d-7000-9001-0000000000a2"

2. Upload the file (multipart)

For a real file on disk, POST to the $upload operation as multipart/form-data. The form takes a file part (the binary), a subject field (a Patient reference or bare UUID), and an optional encounter field to link the document to a visit.

POST/fhir/R4/DocumentReference/$upload
curl -i -X POST https://api.huli.ai/fhir/R4/DocumentReference/\$upload \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Accept: application/fhir+json" \
  -F "file=@resultados-laboratorio.pdf;type=application/pdf" \
  -F "subject=Patient/01965e2a-8c4d-7000-9001-0000000000a2" \
  -F "encounter=Encounter/01965e2a-8c4d-7000-9060-0000000000e9"
import { readFile } from 'node:fs/promises';

const bytes = await readFile('resultados-laboratorio.pdf');
const form = new FormData();
form.set('file', new Blob([bytes], { type: 'application/pdf' }), 'resultados-laboratorio.pdf');
form.set('subject', `Patient/${process.env.PATIENT_ID}`);
form.set('encounter', 'Encounter/01965e2a-8c4d-7000-9060-0000000000e9'); // optional

const resp = await fetch('https://api.huli.ai/fhir/R4/DocumentReference/$upload', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HULI_TOKEN}`,
    Accept: 'application/fhir+json',
    // Do NOT set Content-Type — fetch sets the multipart boundary for you.
  },
  body: form,
});

if (resp.status === 201) {
  const created = (await resp.json()) as { id: string };
  console.log('uploaded', created.id);
} else {
  const outcome = (await resp.json()) as { issue: { diagnostics: string }[] };
  console.log(resp.status, outcome.issue[0].diagnostics);
}
import os
import requests

with open("resultados-laboratorio.pdf", "rb") as fh:
    resp = requests.post(
        "https://api.huli.ai/fhir/R4/DocumentReference/$upload",
        headers={
            "Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
            "Accept": "application/fhir+json",
        },
        files={"file": ("resultados-laboratorio.pdf", fh, "application/pdf")},
        data={
            "subject": f"Patient/{os.environ['PATIENT_ID']}",
            "encounter": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9",  # optional
        },
        timeout=60,
    )

if resp.status_code == 201:
    print("uploaded", resp.json()["id"])
else:
    print(resp.status_code, resp.json()["issue"][0]["diagnostics"])

A 201 Created returns the stored DocumentReference with a server-assigned id and a Location header. The bytes are referenced, not inlined — content[0].attachment.url holds a 30-minute signed URL on the create/read response:

{
  "resourceType": "DocumentReference",
  "id": "01965e2a-8c4d-7000-9070-0000000000f4",
  "meta": {
    "versionId": "1769472901000000000",
    "lastUpdated": "2026-06-26T10:15:01.000-06:00",
    "profile": ["https://fhir.huli.ai/r4/StructureDefinition/HuliDocumentReference"]
  },
  "status": "current",
  "subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2", "type": "Patient" },
  "author": [
    { "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1", "type": "Practitioner" }
  ],
  "date": "2026-06-26T10:15:01.000-06:00",
  "content": [
    {
      "attachment": {
        "contentType": "application/pdf",
        "url": "https://storage.googleapis.com/huli-prod-documents/...&X-Goog-Expires=1800&...",
        "size": 248913,
        "hash": "k3m2Q9c0Vp9d1xQe3rJh8oH2bW5sQ0aZ7tC4uN6vY8=",
        "title": "resultados-laboratorio.pdf"
      }
    }
  ],
  "context": {
    "encounter": [
      { "reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9", "type": "Encounter" }
    ]
  }
}

contentType is server-detected from the bytes, not echoed from your form — proof the magic- byte check ran. hash is the base64 of the file's SHA-256 digest.

3. Upload inline (base64) — the small-payload alternative

When you already hold the bytes in memory, skip multipart and POST a JSON DocumentReference with the binary base64-encoded in content[0].attachment.data. This works on both POST /DocumentReference and the $upload operation. The title (filename) is required so the extension can be matched against the detected type.

POST/fhir/R4/DocumentReference
curl -i -X POST https://api.huli.ai/fhir/R4/DocumentReference \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Content-Type: application/fhir+json" \
  -H "Accept: application/fhir+json" \
  -d '{
    "resourceType": "DocumentReference",
    "status": "current",
    "subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" },
    "context": {
      "encounter": [ { "reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9" } ]
    },
    "content": [
      {
        "attachment": {
          "contentType": "application/pdf",
          "title": "resultados-laboratorio.pdf",
          "data": "JVBERi0xLjQKJ..."
        }
      }
    ]
  }'
import base64
import os
import requests

with open("resultados-laboratorio.pdf", "rb") as fh:
    data = base64.standard_b64encode(fh.read()).decode("ascii")

document = {
    "resourceType": "DocumentReference",
    "status": "current",
    "subject": {"reference": f"Patient/{os.environ['PATIENT_ID']}"},  # required
    "context": {"encounter": [{"reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9"}]},  # optional
    "content": [
        {
            "attachment": {
                "contentType": "application/pdf",
                "title": "resultados-laboratorio.pdf",  # required — extension matched to detected type
                "data": data,  # required — base64 of the bytes
            }
        }
    ],
}

resp = requests.post(
    "https://api.huli.ai/fhir/R4/DocumentReference",
    headers={
        "Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
        "Content-Type": "application/fhir+json",
        "Accept": "application/fhir+json",
    },
    json=document,
    timeout=60,
)
print(resp.status_code, resp.json().get("id") or resp.json()["issue"][0]["diagnostics"])

4. Read the document and download the bytes

Read the DocumentReference by id to get a fresh signed URL, then GET that URL to download. The signed URL expires after 30 minutes — fetch it shortly after the read, and re-read for a new one rather than caching it.

GET/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4
# 1. Read the resource to get a fresh signed URL.
URL=$(curl -s "https://api.huli.ai/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4" \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Accept: application/fhir+json" \
  | jq -r '.content[0].attachment.url')

# 2. Download the bytes (the signed URL needs no Authorization header).
curl -s "$URL" -o resultados-laboratorio.pdf

5. Search a patient's documents

DocumentReference search is patient-scoped, so the patient parameter is required. Narrow with category, type (a LOINC code), or date. Search results carry the metadata but no signed URL — read the individual document (step 4) when you need the bytes.

GET/fhir/R4/DocumentReference?patient=01965e2a-8c4d-7000-9001-0000000000a2&category=laboratory
curl "https://api.huli.ai/fhir/R4/DocumentReference?patient=01965e2a-8c4d-7000-9001-0000000000a2&category=laboratory&_count=20" \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Accept: application/fhir+json"

category codes come from the document-category CodeSystem — laboratory, imaging, clinical_note, prescription, administrative, consent, growth_booklet, other. Search pages with _count + _cursor (follow the next link); the category filter repaginates with an exact total, while type and date filter the current page.

6. Correct mistakes — metadata edit and soft-delete

A PUT either updates the document's metadata (category, description) or — when the body sets status: "entered-in-error" — soft-deletes it. The binary itself is immutable on this surface; to replace the file, upload a new document.

PUT/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4
# Soft-delete a document uploaded against the wrong patient.
curl -i -X PUT https://api.huli.ai/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4 \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Content-Type: application/fhir+json" \
  -H "Accept: application/fhir+json" \
  -d '{ "resourceType": "DocumentReference", "status": "entered-in-error" }'

A 200 OK returns the document with status: "entered-in-error"; it then drops out of reads and searches.

What to verify

  • The upload is 201. 201 Created The response resourceType is DocumentReference with a server-assigned id and a Location header.
  • content[0].attachment.contentType is the detected type (e.g. application/pdf) and size/hash are populated.
  • A read returns a fresh content[0].attachment.url, and a GET on that URL downloads the bytes.
  • The document appears in a patient-scoped search; after an entered-in-error PUT, it no longer does.

What can go wrong

All errors return a FHIR OperationOutcome{severity, code, diagnostics}, no details object. Branch on the HTTP status and issue[0].code; the Huli code is the prefix of issue[0].diagnostics, split on ": ".

413 HPB-00119document too large. The file exceeds the 25 MB

ceiling. Compress or split it; the limit is enforced on the decoded bytes, so a base64 inline payload hits it sooner than its wire size suggests.

400 Bad Request HPB-00120content invalid or type mismatch. The bytes are not

one of PDF/JPEG/PNG/WEBP/DICOM, or the filename extension does not match the detected type (a .pdf whose magic bytes are a PNG). Send the true file with its real extension.

400 Bad Request HPB-00101validation error. A required field is missing — the

multipart file or subject, or, inline, content[0].attachment.data or .title (filename). Add the missing field.

403 Forbidden HPB-00104insufficient scope. The token lackssystem/DocumentReference.cru (or .rs for a read). Because the Clinical information card is BAA-gated, confirm the key was minted with a BAA attestation.404 Not Found HPB-00118DocumentReference not found. The id does not name a

document in your organization, or it was soft-deleted. Confirm the id and the token's organization.

503 Service Unavailable Document storage not configured. This server has no document

storage wired; the surface fails closed rather than accepting an upload it cannot persist. This is an operator-side gap, not a request error.

A representative 413 body:

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "processing",
      "diagnostics": "HPB-00119: Document exceeds the maximum allowed size"
    }
  ]
}

Next recipes