---
title: Uploading a document
description: Attach a lab PDF, scan, or image to a patient as a FHIR R4 DocumentReference — multipart $upload or inline base64 — then read it back via a 30-minute signed URL. Uses the BAA-gated system/DocumentReference.cru scope.
nav: Recipes
order: 37
version: v1
source: handwritten
updated: 2026-06-26
---

# 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: <Scope>system/DocumentReference.cru</Scope> — 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](/v1/auth/bearer) for provisioning
  and [`POST /auth/token`](/v1/auth) for the token exchange.
- The <Scope>system/DocumentReference.cru</Scope> 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; <Scope>system/DocumentReference.rs</Scope> alone grants read + search.
- The `id` of the patient the document belongs to. Resolve it with
  [a Patient search](/v1/recipes/getting-started-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.

<Callout variant="info">
The server detects the real content type from the file's **magic bytes** and requires the
filename extension to match — so the **filename is required** on every upload, and a `.pdf` whose
bytes are actually a PNG is rejected. There is **no `DELETE`** verb: retire a document by `PUT`ing
`status: "entered-in-error"`, which soft-deletes it.
</Callout>

## 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

```bash
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.

<Endpoint method="POST" path="/fhir/R4/DocumentReference/$upload" />

:::CodeGroup

```bash
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"
```

```typescript
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);
}
```

```python
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:

```json
{
  "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.

<Endpoint method="POST" path="/fhir/R4/DocumentReference" />

:::CodeGroup

```bash
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..."
        }
      }
    ]
  }'
```

```python
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"])
```

:::

<Callout variant="note">
Prefer multipart for anything beyond a few hundred kilobytes: base64 inflates the payload ~33%
and counts against the same 25 MB ceiling once decoded. Inline mode is convenient for small,
in-memory payloads; multipart streams the file without the encoding overhead.
</Callout>

### 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.

<Endpoint method="GET" path="/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4" />

```bash
# 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.

<Endpoint method="GET" path="/fhir/R4/DocumentReference?patient=01965e2a-8c4d-7000-9001-0000000000a2&category=laboratory" />

```bash
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.

<Endpoint method="PUT" path="/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4" />

```bash
# 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`. <StatusBadge code="201" /> 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 `": "`.

<StatusBadge code="413" /> `HPB-00119` — **document 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.

<StatusBadge code="400" /> `HPB-00120` — **content 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.

<StatusBadge code="400" /> `HPB-00101` — **validation error.** A required field is missing — the
multipart `file` or `subject`, or, inline, `content[0].attachment.data` or `.title` (filename).
Add the missing field.

<StatusBadge code="403" /> `HPB-00104` — **insufficient scope.** The token lacks
<Scope>system/DocumentReference.cru</Scope> (or `.rs` for a read). Because the **Clinical information** card is BAA-gated, confirm the key was minted with a BAA attestation.

<StatusBadge code="404" /> `HPB-00118` — **DocumentReference 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.

<StatusBadge code="503" /> **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:

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

## Next recipes

- **[Writing and amending a clinical note](/v1/recipes/writing-a-clinical-note)** — the
  `Composition` narrative that sits alongside these documents under the Clinical information card.
- **[Fetching a patient's full record](/v1/recipes/fetching-a-patient-record)** — pull a patient's
  documents together with their encounters, observations, and notes in one `$everything` Bundle.
- **[Creating a clinical encounter](/v1/recipes/creating-an-encounter)** — create the visit you
  link a document to via `context.encounter`.
