Amy
Recipes

Recipe, 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 a processing status; parsing happens asynchronously and lands ~30s later. Treat the API like an inbox, not a sync function, POST then poll GET /v1/labs/:id until status flips to parsed.

1. Client uploads file via multipart  →  202 { id, status: "processing" }
2. File lands in R2 immediately.
3. Terra OCR runs in background (~30s).
4. Webhook `lab_report.processed` arrives.
5. Backend writes biomarkers; lab row flips to status: "parsed".
6. Client poll on GET /v1/labs/:id sees the new status + biomarkers.

STEP 1, Know the limits

LimitValue
Max file size10 MB
Allowed content typesapplication/pdf, image/jpeg, image/png, image/heic
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

Upload anything outside those limits and the API returns 400 invalid_request immediately (size) or 400 invalid_file_type (mime). 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" \
  -H "Authorization: Bearer $AMY_API_KEY" \
  -F "file=@panel.pdf;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 (202 Accepted):

{
  "id": "lab_01HX2K3M4N5P6Q7R8S9T0V1W2X",
  "status": "processing",
  "filename": "panel.pdf",
  "uploaded_at": "2026-05-25T10:00:00Z"
}

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.id, lab.status); // lab_01HX…  processing

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.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`, {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` }, // do NOT set Content-Type — let fetch set the boundary
  body: fd,
});
const lab = await res.json();

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.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`, {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}` },
  body: fd,
});

STEP 3, Poll until parsed

The upload returned a lab_… id with status: "processing". Poll GET /v1/labs/:id every few seconds until status flips. ~30 seconds is typical; give it up to 2 minutes before treating it as stuck.

curl

LAB_ID="lab_01HX…"
while true; do
  RES=$(curl -s -H "Authorization: Bearer $AMY_API_KEY" \
    "$AMY_BASE_URL/v1/labs/$LAB_ID")
  STATUS=$(echo "$RES" | jq -r .status)
  echo "status: $STATUS"
  [ "$STATUS" = "parsed" ] && { echo "$RES" | jq .biomarkers; break; }
  [ "$STATUS" = "failed" ] && { echo "$RES" | jq .error; break; }
  sleep 3
done

TypeScript

async function pollUntilParsed(amy: Amy, id: string, timeoutMs = 120_000) {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const lab = await amy.labs.retrieve(id);
    if (lab.status === "parsed") return lab;
    if (lab.status === "failed") {
      throw new Error(lab.error ?? "lab parse failed");
    }
    await new Promise((r) => setTimeout(r, 3_000));
  }
  throw new Error(`lab ${id} still processing after ${timeoutMs}ms`);
}

Parsed response:

{
  "id": "lab_01HX…",
  "status": "parsed",
  "filename": "panel.pdf",
  "uploaded_at": "2026-05-25T10:00:00Z",
  "parsed_at": "2026-05-25T10:00:32Z",
  "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 concerning = lab.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 status: "failed"

OCR fails sometimes. Reasons we've seen: photo too blurry, PDF password-protected, scan rotated 90°, lab format Terra doesn't recognize. The failed payload includes an error field with a short human-readable reason.

{
  "id": "lab_01HX…",
  "status": "failed",
  "filename": "panel.pdf",
  "error": "OCR confidence below threshold — please re-upload a clearer copy."
}

What to do in your UI: show the error message 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
400 invalid_file_typeYou sent application/octet-stream (curl's default for raw -F file=@…)Add ;type=application/pdf to the curl flag, or set Blob({type: …}) in JS
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 400 invalid_request. 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 is allowed and usually small, but iPhone "Live Photos" can balloon files. 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 202, immediately call GET /v1/labs/:id, see status: "processing", 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 is stuck in processing 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