Core Concepts
Session lifecycle, data model, biomarker standardization, and async processing.
Session Lifecycle
Every lab report upload creates a session. The session tracks the report through extraction, standardization, and delivery.
┌──────────────────────────────┐
│ Upload File │
│ POST /v2/reports │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ processing │
│ OCR + AI extraction of │
│ biomarker data from file │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ processed │
│ Raw results extracted │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ standardizing │
│ Fuzzy matching biomarkers, │
│ normalizing units & ranges │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ standardized │
│ Results ready (queryable) │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ sending │
│ Delivering to destinations │
└──────────────┬───────────────┘
│
▼
┌──────────────────────────────┐
│ sent │
│ Delivered (terminal) │
└──────────────────────────────┘
At any point, a failure moves the session to:
┌──────────────────────────────┐
│ failed │
│ Error (terminal) │
└──────────────────────────────┘Status Reference
processing
Extracting biomarker data from the file
No
No
processed
Extraction complete
No
No
standardizing
Matching biomarkers and normalizing units
No
No
standardized
Standardization complete, results queryable
No
Yes
sending
Delivering the report to your configured destinations
No
Yes
sent
Delivered to all opted-in destinations
Yes
Yes
partially_sent
Delivered to at least one destination, failed others
Yes
Yes
failed
An error occurred (check status history for info)
Yes
Partial/No
retry_scheduled
Retry has been scheduled
No
Partial/No
retrying
Retry in progress
No
Partial/No
cancelled
Processing was cancelled
Yes
Partial/No
deleted
Session has been deleted
Yes
No
Report Types
One endpoint ingests two kinds of document: clinical lab reports and DXA scans (body composition and bone density). You do not declare which — Terra classifies the file on upload and reports what it decided in report_type.
report_type
Document
Results are scoped by
lab
Clinical laboratory report
biomarker.specimen
dexa
DXA body-composition / bone scan
biomarker.region
unspecified
Not yet classified (transient)
—
Branch on report_type before reading results: the two carry different scoping fields, and a scan reports the same measure once per anatomical site. See Scans (DEXA).
Data Model
The data model is a hierarchy: Session -> Results -> Reference Ranges. Each result is grouped into four layers — source (verbatim from the report), biomarker (normalized identity), measurement (one typed value), and interpretation (coded abnormality signal).
Session Object
session_id
string
Snowflake int64 ID (as string)
upload_id
string
The upload this report came from
reference_id
object?
Your external identifier (from upload query param)
current_status
string
Current session status (e.g. "sent")
report_type
string
Report type (e.g. "lab")
report_date
object?
Date from the lab report (YYYY-MM-DD)
report_time
object?
Time from the lab report (HH:MM 24-hour)
collection_date
object?
Specimen collection date (YYYY-MM-DD)
collection_time
object?
Specimen collection time (HH:MM 24-hour)
uploaded_at
object?
Session creation time (ISO-8601)
updated_at
object?
Last update time (ISO-8601)
report_locale
object?
Locale of the report (e.g. "ja-JP", "pt-BR")
lab_name
object?
Name of the laboratory
patient_sex
string
"male", "female", or omitted if unspecified
patient_age_at_collection
integer?
Patient age in years at specimen collection
report_notes
string
Free-text notes extracted from the report
status_history
object[]
Array of status change entries
results_count
integer
Number of biomarker results
file_count
integer
Number of uploaded files
input_bytes
integer
Size of uploaded file(s) in bytes
output_bytes
integer
Size of output data in bytes
panels
object[]
Report-level panels that results reference by panel_id (omitted if the report has no panel grouping)
results
object[]
Array of biomarker result objects
Biomarker Standardization
When a lab report is processed, each extracted result goes through biomarker standardization:
Fuzzy Matching — The
source.namefrom the report is matched against Terra's reference dataset of 4,200+ known biomarkers using fuzzy string matching.Key Assignment — If a match is found,
biomarker.keyis set to a canonical slug (e.g.,"hemoglobin","glucose_fasting"). If no confident match exists,biomarker.keyisnull.Display Name — A standardized English
biomarker.display_nameis assigned from the matched biomarker.Unit Normalization — The
source.units(as printed on the report) are mapped to a UCUM-compliantmeasurement.ucum_codewhere possible.
Entries are pooled by report type: a scan's results are matched only against scan entries and a lab report's only against lab entries, so a body-composition measure can never be mistaken for a blood analyte. A handful of entries (height, weight, BMI) sit in both pools because both kinds of report print them.
Site-scoped results
On a lab report, biomarker.key identifies a result — a biomarker appears once.
On a scan it does not. The same measure is reported once per place on the body: a total-body scan carries fat mass for the head, each limb, the trunk, android, gynoid and the whole body, all under biomarker.key = fat_mass. What separates them is biomarker.region.
The identity of a scan result is the pair (
biomarker.key,biomarker.region).
biomarker.region is the canonical key from a closed vocabulary — use it for storage and joins. source.region_name is the site as the report printed it — use it for display.
This matters for storage: keying on biomarker.key alone keeps one row per measure and silently discards every other site. See Scans (DEXA) for the vocabulary and Best Practices for the storage pattern.
See Biomarker Reference for details on the matching system and a list of common biomarkers.
UCUM Unit Codes
Terra maps lab report units to UCUM (Unified Code for Units of Measure) codes wherever possible. This gives you a machine-readable, internationally standardized representation of the unit.
Report Prints
ucum_code
g/dL
g/dL
mg/dL
mg/dL
mmol/L
mmol/L
10^9/L
10*9/L
mIU/L
m[IU]/L
pg/mL
pg/mL
When UCUM mapping is not possible (e.g., proprietary lab units), measurement.ucum_code is omitted and source.units still reflects what the report printed.
Measurement Types
Every result carries a single measurement object. Read measurement.type, then read the matching field:
measurement.type
Read this field
Description
Example
numeric
measurement.numeric
A numeric measurement with optional units
Hemoglobin: 14.2 g/dL
bounded
measurement.bounded
A one-sided bound: { operator: "lt"|"gt", value: number }
PSA: <0.04
qualitative
measurement.qualitative
A categorical outcome: { text: string }
HIV screen: Non-Reactive
text
measurement.text
Free-text observation
Morphology: Normal appearance
absent
measurement.absent_reason
No value could be extracted
Value not reported
measurement.units and measurement.ucum_code describe the magnitude for numeric/bounded results. For a report showing <0.01, measurement.type is "bounded" and measurement.bounded is { "operator": "lt", "value": 0.01 }; for >5.0 it is { "operator": "gt", "value": 5.0 }.
Interpretation Flag
Each result's interpretation object carries the coded per-result abnormality signal. interpretation.flag is a normalized code (or null when there is no signal), while interpretation.flag_raw preserves the lab's verbatim flag text and source.flag carries that raw text on the source layer. interpretation.source is "report" when the flag was normalized from the printed flag, or "none" when there is no signal.
interpretation.flag
Meaning
Common raw flag(s)
normal
Not flagged
"N"
high
Above range
"H", "↑"
low
Below range
"L", "↓"
critical_high
Critically high
"HH"
critical_low
Critically low
"LL"
borderline_high
Borderline high
borderline_low
Borderline low
abnormal
Flagged, direction unspecified
"A", "C"
interpretation.flag is null when the lab did not flag that result. The raw lab string it was derived from is preserved in interpretation.flag_raw and source.flag — those are the lab's own text (e.g. "H", "↑", "Négatif"), not an enum.
Async Processing Model
Lab report processing is fully asynchronous:
Upload —
POST /v2/reportsreturns202 Acceptedimmediately with asession_id.Processing — Extraction and standardization happen in the background (typically 30 seconds to 2 minutes).
Delivery — Once complete, the full session payload is delivered to your webhook. You can also poll
GET /v2/reports/{session_id}at any time.
Delivery Destinations
Lab reports are not webhook-only. A completed report can be delivered to any destination type you have configured — webhooks, cloud storage (S3, GCS, Azure Blob), message queues (SQS, Kafka), and databases (MongoDB, Firestore, SQL, Supabase).
Opting a Destination In
Delivery is opt-in per destination. A destination receives lab reports only if its event-type allow-list explicitly includes lab reports — destinations that don't opt in are simply skipped (they aren't marked failed, and skipping one never affects the others).
Set the allow-list with the destination_event_types field when you create or update a destination, and include "lab_report" in it:
A destination's destination_event_types may list more than one event type. Add "lab_report" to an existing destination's array to start sending lab reports there alongside whatever it already receives.
Per-Destination Outcomes
Each destination's delivery is tracked independently. A report fanned out to several destinations records a separate outcome (pending → delivered/failed) for each one, so a failure to one destination never hides that the report reached the others — and never fails the report as a whole.
When some destinations succeed and others fail, the session ends in the partially_sent status (terminal). Retrieve the per-destination state — including the destination_type, status, attempt_count, and any last_error — from the /deliveries sub-resource.
How Each Type Receives the Report
Storage destinations (S3, GCS, Azure Blob, Supabase) and document databases (MongoDB, Firestore) receive the full report body.
Row and message destinations (SQL, SQS, Kafka) receive a link to the report (a time-limited URL) rather than the full body — fetch the report from that link, or from
GET /v2/reports/{session_id}.
Timestamps and Serialization
All timestamps in responses are ISO-8601 format (e.g., "2026-03-28T14:22:10Z").
All enum values are serialized as clean lowercase strings, not proto-style names or integer codes:
Status:
"sent"(not"REPORT_STATUS_SENT"or6)Measurement type:
"numeric"(not"LAB_REPORT_RESULT_TYPE_NUMERIC")Specimen:
"blood"(not"LAB_REPORT_SPECIMEN_TYPE_BLOOD")Reference range type:
"normal"(not"LAB_REPORT_RANGE_CLASSIFICATION_NORMAL"); the report's own wording is inlabelInterpretation flag:
"high"(not"LAB_REPORT_FLAG_HIGH")Biological sex:
"male"(not"BIOLOGICAL_SEX_MALE")
The raw lab flag on source.flag (and mirrored in interpretation.flag_raw) is the lab's own string (e.g., "H", "L"), not an enum. The normalized interpretation.flag is an enum.
Optional fields that are unset are omitted from the JSON response rather than appearing as null.
Session IDs
Session IDs are snowflake int64 values. In JSON they are serialized as strings to prevent precision loss in languages like JavaScript where Number cannot represent the full int64 range.
Always store and compare session IDs as strings.
Last updated
Was this helpful?