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/tokenfor 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.
.crugrants upload + read + update; system/DocumentReference.rs alone grants read + search. - The
idof 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.
/fhir/R4/DocumentReference/$uploadcurl -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.
/fhir/R4/DocumentReferencecurl -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.
/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.
/fhir/R4/DocumentReference?patient=01965e2a-8c4d-7000-9001-0000000000a2&category=laboratorycurl "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.
/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 responseresourceTypeisDocumentReferencewith a server-assignedidand aLocationheader. content[0].attachment.contentTypeis the detected type (e.g.application/pdf) andsize/hashare populated.- A read returns a fresh
content[0].attachment.url, and aGETon that URL downloads the bytes. - The document appears in a
patient-scoped search; after anentered-in-errorPUT, 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 ": ".
HPB-00119 — document too large. The file exceeds the 25 MBceiling. 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 RequestHPB-00120 — content invalid or type mismatch. The bytes are notone 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.
HPB-00101 — validation error. A required field is missing — themultipart file or subject, or, inline, content[0].attachment.data or .title (filename). Add the missing field.
HPB-00104 — insufficient 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-00118 — DocumentReference not found. The id does not name adocument 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 documentstorage 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
- Writing and amending a clinical note — the
Compositionnarrative that sits alongside these documents under the Clinical information card. - Fetching a patient's full record — pull a patient's documents together with their encounters, observations, and notes in one
$everythingBundle. - Creating a clinical encounter — create the visit you link a document to via
context.encounter.