Blood Test PDF to Structured Biomarkers: Lab Reports API
TL;DR: Blood test results reach most health apps as PDFs. Which is to say, they don't reach them at all. A lab prints a report, the patient downloads it, and every value inside is invisible to your product unless a human types it in. Our Lab Reports API takes that file and returns structured biomarker data: canonical biomarker keys, LOINC codes, typed values with UCUM units, normalized flags, and demographic reference ranges. It's essentially one call in and one webhook out.
curl -X POST "https://access.tryterra.co/api/v2/lab-reports" \
-H "dev-id: YOUR_DEV_ID" \
-H "x-api-key: YOUR_API_KEY" \
-F "file=@blood-test.pdf"{ "upload_id": "upl_4a2b8c1d", "current_status": "processing" }That's the whole integration. If you'd rather try it than read about it, the API reference has every endpoint and the quickstart gets you a parsed report in three curl commands.
This is part one of three. This post is about why lab reports were the problem in the first place. Part two covers the data model. Part three covers the pipeline.
Blood work is the missing half of health data
The health data that flows into apps automatically is the data devices produce: steps, sleep, heart rate. Continuous, abundant, born digital. Blood work is the opposite on every axis. It's sparse. A user might test twice a year, maybe less. It's dense, too. One blood draw can tell you more about a person than a month of wrist data. And it's born on paper, or on a PDF that is functionally paper.
So you get a strange inversion. The lowest stakes data in a user's health picture arrives automatically, and the highest stakes data sits in an attachment folder somewhere. Teams building preventive health, longevity, and nutrition products end up with the same lopsided product: automated end to end on the wearable side, and on the blood side, a person reading a PDF and filling in a form. That's not a pipeline. That's a job.
Why OCR and string matching fail on blood test PDFs
Every team hits this problem and tries the same first move. Run the report through OCR, then match lines against a list of biomarker names. It fails three ways.
First, there is no standard lab report. None. Every lab lays results out differently. Different columns, different headers, results split across pages, footnotes that quietly change what a value means. A parser tuned on one lab's layout misreads the next one.
The second part is the naming. The same analyte shows up as any of these depending on the lab and the country:
Haemoglobin (Hb)
Hemoglobin
HGB
HemoglobinaMatch them with strings and whether two results merge depends on which spellings someone happened to add to a list, not on whether they're the same measurement. Merge wrongly and you corrupt a trend line. Fail to match and the value just vanishes. This is the same failure mode we wrote about with exercise names in strength training, and it's worse here, because here the values are clinical.
Third, units and reference ranges. Glucose can be reported in mg/dL by one lab and mmol/L by another. Ranges shift with sex, age, pregnancy, fasting status. A parser that keeps the number but drops the range has kept the useless half of the result. A haemoglobin of 14.2 means nothing on its own. You need to know what the lab considered normal for that particular person.
How the Terra Lab Reports API extracts biomarkers from a PDF
You send the file. PDF, PNG, JPEG, GIF, or WebP, up to 20 MB, in one HTTP call. Each result comes back layered, with the lab's exact printed words preserved right next to the standardized version:
{
"source": { "name": "Haemoglobin (Hb)", "value": "14.2",
"flag": "H", "reference_text": "13.0 - 17.0 (Male)" },
"biomarker": { "key": "hemoglobin_blood", "loinc_code": "718-7" },
"measurement": { "type": "numeric", "numeric": 14.2, "ucum_code": "g/dL" },
"interpretation": { "flag": "high",
"applied_range": { "lower": 13.0, "upper": 17.0 } }
}The source layer is an audit trail: a clinician can always check the structured value against what the lab actually printed. The biomarker layer is a stable identity, with a LOINC code (the international standard identifier for lab observations) where one exists, so the same analyte lines up across labs. The measurement is typed and carries a UCUM unit code, so values are comparable programmatically. The interpretation is a normalized high, low, or normal flag plus the exact range that produced it.
It doesn't matter which lab produced the report or what language it's printed in. Your backend sees the same shape either way.
When a result matches nothing in our catalog, we keep it anyway:
{
"source": { "name": "Some Obscure Assay", "value": "3.1" },
"biomarker": { "key": null }
}Nothing a lab printed is ever discarded. Dropping unmatched results would just recreate the string matching bug one layer up.
One schema across blood, wearables, and sensors
Why do this inside Terra rather than as a standalone parsing tool? Because of what happens after the parse. Lab results go to the same destinations as your wearable data, joined on your own reference_id:
{ "reference_id": "patient_456", "provider": "GARMIN",
"data": [{ "scores": { "sleep": 78, "readiness": 64 } }] }{ "reference_id": "patient_456", "type": "lab_report.completed",
"data": { "panels": ["cbc", "thyroid"], "results": [ ... ] } }If you're already using Terra for wearables, there's nothing new to build here. Lab results just start showing up where your other data does. The point of that is what it lets you show. A cholesterol number on its own doesn't mean much to a user. The same number next to their own sleep and training data from the weeks around the test starts to. Getting that combination used to mean two vendors and some glue code to join them, and the glue code was always yours to maintain.
Where the line is
One thing we're explicit about, because it shaped the entire data model: Terra supplies the signal, not the clinical call. We normalize the flag the lab printed and we attach the range that was applied. Which markers matter, what counts as concerning, and what a user should do next are your calls, made by your clinicians, in your product. Terra acts as a data processor. We don't need to retain the data to deliver it.
The rest of the series
Part two goes inside a single result: the four layers of the payload, and why unmatched results ship with a null key instead of a guessed one. Part three is the pipeline: the session lifecycle, webhooks versus polling, delivery destinations, and the sharp edges worth knowing before production.
If you'd rather not wait, the quickstart gets you a parsed report in three curl commands. Contact us to enable it on your account.











