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
| Limit | Value |
|---|---|
| Max file size | 10 MB |
| Allowed content types | application/pdf, image/jpeg, image/png, image/heic |
| 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 |
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 "[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 (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… processingBrowser (<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
doneTypeScript
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:
| Symptom | Cause | Fix |
|---|---|---|
400 invalid_file_type | You 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/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 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
- 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 with Claude Code
For non-developers. This recipe assumes you have no coding experience and are using Claude Code (the terminal AI agent) to drive the work. You don't need to understand any of the code Claude generate…