Amy
Recipes

Upload a lab report

Goal: let a user upload a PDF (or phone photo) of their bloodwork, wait for it to be OCR'd and parsed into structured biomarkers, and surface "out of range" markers in your UI.

Goal: let a user upload a PDF (or phone photo) of their bloodwork, wait for it to be OCR'd and parsed into structured biomarkers, and surface "out of range" markers in your UI.

Labs flow through a two-phase lifecycle: upload returns immediately with an upload_id and a terra_status; parsing happens asynchronously and the biomarkers land ~30s later. Treat the API like an inbox, not a sync function — POST /v1/labs/upload, then watch for the parsed biomarkers to show up.

1. Client uploads file via multipart  →  200 { ok, upload_id, storage_key, terra_status, note }
2. File lands in R2 immediately.
3. Terra OCR runs in background (~30s).
4. Webhook `lab_report` arrives.
5. Backend writes biomarkers; the upload's terra_status advances.
6. Biomarkers surface in the next amy.data.sync() payload.

STEP 1, Know the limits

LimitValue
Max file size10 MB
Allowed content typesapplication/pdf, image/jpeg, image/png
Field namefile (multipart form-data)
Parse latency~30s typical, up to ~2min for dense panels
Per-user concurrent labsNo hard cap, but be considerate, each one OCRs a PDF

HEIC isn't accepted yet. The OpenAPI contract declares image/heic, but the live upload route currently rejects it — send PDF, PNG, or JPEG. Export an iPhone HEIC photo as JPEG first.

Upload anything outside those limits and the API rejects it: an oversize file (>10 MB) returns 413 unprocessable, a disallowed mime type returns 415 unprocessable, and a malformed multipart body returns 400 invalid_request. A 10 MB+ PDF often means a photo-scanned multi-page report, compress or split it before sending.


STEP 2, Upload the file

The upload itself is a standard multipart POST. Every platform has a slightly different ergonomic for building the form; the wire format is identical.

curl

curl -X POST "$AMY_BASE_URL/v1/labs/upload" \
  -H "Authorization: Bearer $AMY_API_KEY" \
  -F "[email protected];type=application/pdf"

The @ prefix tells curl to read from a file. The ;type=… clause forces the right content-type, without it curl may default to application/octet-stream, which the API rejects.

Response (200 OK):

{
  "ok": true,
  "upload_id": "lab_01HX2K3M4N5P6Q7R8S9T0V1W2X",
  "storage_key": "labs/user_…/lab_01HX2K3M4N5P6Q7R8S9T0V1W2X.pdf",
  "terra_status": "submitted",
  "note": "Parsing runs asynchronously; biomarkers appear in your next data sync."
}

TypeScript (Node / Bun)

The SDK takes a Blob, a File, or a ReadableStream. It picks the right field name and content-type for you.

import { Amy } from "@amy/sdk";
import { readFile } from "node:fs/promises";

const amy = new Amy({
  apiKey: process.env.AMY_API_KEY!,
  baseUrl: process.env.AMY_BASE_URL!,
});

const bytes = await readFile("./panel.pdf");
const blob = new Blob([bytes], { type: "application/pdf" });

const lab = await amy.labs.upload({
  file: blob,
  filename: "panel.pdf",
});

console.log(lab.upload_id, lab.terra_status); // lab_01HX…  submitted

Browser (<input type="file"> + FormData)

The native File returned by <input> already has the right content-type set. Hand it straight to the SDK.

<input type="file" id="lab" accept="application/pdf,image/*" />
import { Amy } from "@amy/sdk";

const amy = new Amy({ apiKey, baseUrl });

document.getElementById("lab")!.addEventListener("change", async (e) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (!file) return;

  if (file.size > 10 * 1024 * 1024) {
    alert("File too large. Max 10 MB.");
    return;
  }

  const lab = await amy.labs.upload({ file });
  pollUntilParsed(lab.upload_id);
});

If you want to build the form yourself instead of using the SDK:

const fd = new FormData();
fd.append("file", file);

const res = await fetch(`${baseUrl}/v1/labs/upload`, {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` }, // do NOT set Content-Type — let fetch set the boundary
  body: fd,
});
const lab = await res.json(); // { ok, upload_id, storage_key, terra_status, note }

React Native (expo-document-picker)

import * as DocumentPicker from "expo-document-picker";
import { Amy } from "@amy/sdk";

const amy = new Amy({ apiKey, baseUrl });

async function pickAndUpload() {
  const result = await DocumentPicker.getDocumentAsync({
    type: ["application/pdf", "image/jpeg", "image/png"],
    copyToCacheDirectory: true,
  });

  if (result.canceled) return;

  // SDK accepts the picker asset directly — it knows how to read uri/name/mimeType.
  const lab = await amy.labs.upload({ file: result.assets[0] });
  pollUntilParsed(lab.upload_id);
}

For native fetch without the SDK, build a FormData object with the file's uri (React Native's quirk):

const fd = new FormData();
fd.append("file", {
  uri: result.assets[0].uri,
  name: result.assets[0].name,
  type: result.assets[0].mimeType,
} as any);

await fetch(`${baseUrl}/v1/labs/upload`, {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` },
  body: fd,
});

STEP 3, Poll until parsed

The upload returned an upload_id with an early terra_status. Poll the labs list (GET /v1/labs) every few seconds and watch that upload's terra_status advance to parsed. ~30 seconds is typical; give it up to 2 minutes before treating it as stuck. The parsed biomarkers themselves surface in your next amy.data.sync() payload.

curl

UPLOAD_ID="lab_01HX…"
while true; do
  RES=$(curl -s -H "Authorization: Bearer $AMY_API_KEY" \
    "$AMY_BASE_URL/v1/labs")
  STATUS=$(echo "$RES" | jq -r --arg id "$UPLOAD_ID" \
    '.uploads[] | select(.id == $id) | .terra_status')
  echo "terra_status: $STATUS"
  [ "$STATUS" = "parsed" ] && break
  case "$STATUS" in failed*) echo "parse failed"; break;; esac
  sleep 3
done

# Biomarkers land in the data-sync payload, not the labs list.
curl -s -H "Authorization: Bearer $AMY_API_KEY" \
  "$AMY_BASE_URL/v1/data/sync" | jq .biomarkers

TypeScript

// `amy.labs.retrieve(id)` is Planned — until it ships, poll the list
// endpoint and find the row by id. (Or call /v1/labs/:id directly when
// it lands; the SDK will add the helper.)
async function pollUntilParsed(amy: Amy, id: string, timeoutMs = 120_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const { uploads } = await amy.labs.list();
    const lab = uploads.find((l) => l.id === id);
    if (lab?.terra_status === "parsed") {
      // Biomarkers arrive via `amy.data.sync()` — pull the latest delta.
      const delta = await amy.data.sync();
      return { lab, biomarkers: delta.biomarkers };
    }
    if (lab?.terra_status?.startsWith("failed")) {
      throw new Error(`lab parse failed: ${lab.terra_status}`);
    }
    await new Promise((r) => setTimeout(r, 3_000));
  }
  throw new Error(`lab ${id} still processing after ${timeoutMs}ms`);
}

Once terra_status is parsed, the biomarkers appear in the biomarkers array of the amy.data.sync() payload:

{
  "biomarkers": [
    {
      "name": "ldl_cholesterol",
      "value": 124,
      "unit": "mg/dL",
      "reference_range": "<100",
      "out_of_range": true
    },
    {
      "name": "hba1c",
      "value": 5.4,
      "unit": "%",
      "reference_range": "4.0-5.6",
      "out_of_range": false
    }
  ]
}

Use out_of_range: true as the flag for surfacing "needs attention" markers in your UI:

const { biomarkers } = await amy.data.sync();
const concerning = biomarkers.filter((b) => b.out_of_range);
for (const b of concerning) {
  console.log(`⚠ ${b.name}: ${b.value} ${b.unit} (ref: ${b.reference_range})`);
}

STEP 4, Handle a failed parse

OCR fails sometimes. Reasons we've seen: photo too blurry, PDF password-protected, scan rotated 90°, lab format Terra doesn't recognize. When parsing fails, the upload's terra_status advances to a failed… value (e.g. failed_ocr) instead of parsed — that's the signal your poll watches for:

// one entry from GET /v1/labs → uploads[]
{
  "id": "lab_01HX…",
  "terra_status": "failed_ocr",
  "uploaded_at": "2026-05-25T10:00:00Z"
}

What to do in your UI: surface a retry prompt and offer "Upload again." Failed labs don't auto-retry, they're terminal.

const lab = await pollUntilParsed(amy, id).catch((err) => {
  showError(`Lab parse failed: ${err.message}`);
  showRetryButton();
  return null;
});

Common mistakes

Wrong content-type

The two failure modes:

SymptomCauseFix
415 unprocessableYou sent application/octet-stream (curl's default for raw -F file=@…) or a disallowed typeAdd ;type=application/pdf to the curl flag, or set Blob({type: …}) in JS. Allowed types: PDF, PNG, JPEG.
Browser fetch sets Content-Type: application/jsonYou manually set Content-Type while sending FormDataDon't set Content-Type at all when using FormData. The browser sets it with the multipart boundary.

File too big

10 MB is the hard cap. The API will reject anything larger with 413 unprocessable. Common causes:

  • A multi-page lab scanned as one PDF with high-DPI images. Re-export at 150 dpi or compress with ps2pdf -dPDFSETTINGS=/ebook.
  • An iPhone HEIC photo of a printed report. HEIC isn't accepted yet (send JPEG), and iPhone "Live Photos" can balloon files anyway — export as JPEG instead.
  • Scanned A3 sheets. Crop to the data region; trim margins.

Pre-flight check in your client so the user doesn't wait for an upload to reject:

if (file.size > 10 * 1024 * 1024) {
  alert(`File is ${(file.size / 1024 / 1024).toFixed(1)} MB. Max is 10 MB.`);
  return;
}

Treating the upload like a sync API

You POST, get 200 with an early terra_status, immediately check the labs list, see it's not parsed yet, and decide the upload failed. It didn't, you're just one second into a 30-second job. Always poll, with a reasonable backoff and timeout.

The same logic shows up as a UX bug: an upload progress bar that hits 100% and then immediately says "parsed: 0 biomarkers." That's because the UI is reading the upload response, not the parsed response. Bind the UI to the result of the poll, not the result of the POST.

Forgetting that the file lands in R2 before it parses

The file is durably stored before OCR runs. If you upload and immediately disconnect, the parse still happens. The upload itself is the only step that requires the user be online; everything after is fire-and-forget.

This also means: don't re-upload the same file thinking it'll "kick the parser." It just creates a second lab row. If a lab's terra_status is stuck short of parsed for >2 minutes, it's stuck on Terra's side, there's nothing client-side to retry.

Polling forever

Set a deadline. The pollUntilParsed helper above caps at 120 seconds; adjust based on what your UI can tolerate. After the deadline, show "still processing, check back later" and let the user navigate away. The lab will eventually parse without them watching.


Where to next

On this page