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
| Limit | Value |
|---|---|
| Max file size | 10 MB |
| Allowed content types | application/pdf, image/jpeg, image/png |
| Field name | file (multipart form-data) |
| Parse latency | ~30s typical, up to ~2min for dense panels |
| Per-user concurrent labs | No 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… submittedBrowser (<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 .biomarkersTypeScript
// `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:
| Symptom | Cause | Fix |
|---|---|---|
415 unprocessable | You sent application/octet-stream (curl's default for raw -F file=@…) or a disallowed type | Add ;type=application/pdf to the curl flag, or set Blob({type: …}) in JS. Allowed types: PDF, PNG, JPEG. |
Browser fetch sets Content-Type: application/json | You manually set Content-Type while sending FormData | Don'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
- A full mobile screen built around this: Recipe: Build a mobile app, see Step 7.
- Querying biomarkers across uploads (trends over time): API reference: Data, biomarkers.
- The webhook side (when the parse finishes): Concepts: Webhooks.
- Endpoint reference: API reference: Labs.
Connect a wearable
Goal: let a user link their Whoop / Oura / Garmin / Fitbit / Apple Watch (or any of the 30+ providers Terra supports) to Amy. End state: a new row in GET /v1/sources and data flowing in via the Terra…
Build a mobile app
A production-quality iOS-first React Native app for the Amy backend — Expo SDK 56, Expo Router v6 native tabs, Clerk Core 3 (@clerk/expo), NativeWind v4, Reanimated 3, TanStack Query v5, @amy/sdk as a workspace import. Words-first, journal-like — not a dashboard.