Migrating from the legacy API
Move from the legacy report upload endpoint to /v2/reports. Endpoint, auth, payload and webhook changes, with a field-by-field mapping.
If you upload reports to api.tryterra.co/v2/lab-reports (or /v2/reports/upload), you are on the legacy stack. This guide moves you to /v2/reports.
Nothing breaks while you migrate. Both stacks run in parallel, and you can switch one environment at a time.
Why move
DEXA and DXA scans. The new endpoint takes body-composition and bone-density scans as well as blood panels, and tells you which it received. The legacy endpoint is lab-only.
Results you can act on without parsing prose. LOINC codes, panel grouping, a normalised flag alongside the report's own wording, and reference ranges that carry both the lab's exact label and a coded type.
Delivery you can see. A per-destination delivery record, a stable
event_idto deduplicate on, and an explicit failure event.Filtering and retrieval. List by reference, report type or date range, and fetch a session's input files.
Before you start
You need the Lab Reports capability on your account. If your uploads return 403 lab reports is not enabled for this account, enable it in the dashboard or talk to your account contact. It is a subscription flag, not an API change.
Check that you have a destination configured for the environment you are migrating. Reports are delivered to the destinations set up on your dev-id, and a report with none configured still finishes as sent, with nothing sent anywhere. If your first migrated upload completes but no webhook arrives, this is almost always why.
Your billing does not change. Both stacks meter the same unit, one per extracted result row, onto the same meter. A mid-migration month bills exactly as before.
1. Change the endpoint and auth
Authentication moves from a single key to a key plus your dev-id.
POST /v2/lab-reports · POST /v2/reports/upload
POST /v2/reports
DELETE /v2/lab-reports/{upload_id}
DELETE /v2/reports/{session_id}
None
GET /v2/reports, with filters
None
GET /v2/reports/{session_id}
None
GET /v2/reports/{session_id}/files
None
GET /v2/reports/{session_id}/deliveries
The new endpoint accepts one file per request, up to 20 MB: PDF, PNG, JPEG, GIF or WebP.
2. Learn the two identifiers
The legacy API had one id. The new API has two, and the distinction matters.
upload_id: the handle for what you uploaded. You still get one back from the upload call.session_id: one extracted report. A single upload can produce more than one session (a multi-report PDF), sosession_idis what identifies a result set, and what every other endpoint takes.
If your code stores the value returned by upload and later looks up results with it, that is the one change you cannot skip. Store upload_id, then learn the session_id(s) from the webhook, or from GET /v2/reports?upload_id=....
3. Update your webhook handler
The legacy webhook delivered one body per report:
The new webhook is an event envelope. Branch on type first:
Three things to handle:
typeislab_report.completedorlab_report.failed. The legacy stack had no failure event: a failed report simply never arrived. Handlelab_report.failedso a bad upload surfaces in your product instead of hanging.event_idis stable across redeliveries. Use it as your idempotency key.datais an object, not an array. Where you looped overdata[], you now readdata.results[]for one session, and expect one event per session.
Check data.report_type to tell a lab panel (lab) from a DEXA scan (dexa). unspecified means Terra has not classified it.
4. Map the result fields
Every legacy field still exists; most moved into a layer that says what kind of thing it is. source is what the report literally said, biomarker is what Terra matched it to, measurement is the normalised value, and interpretation is the verdict.
original_name
source.name
Verbatim from the report
display_name
biomarker.display_name
biomarker
biomarker.key
null when unmatched, as before
type
biomarker.specimen
blood, serum, urine…
value
measurement.numeric
value_bound.greater_than / .less_than
measurement.bounded
Now { "operator": ">", "value": 5 }
display_units
source.units
The report's own units
unit_code
measurement.ucum_code
units (structured object)
Removed
Use measurement.ucum_code
notes
source.notes
reference_ranges[].reference_lower_bound
reference_ranges[].lower
reference_ranges[].reference_upper_bound
reference_ranges[].upper
reference_ranges[].classification
reference_ranges[].label
Verbatim; type carries the coded value
reference_ranges[].context
reference_ranges[].context
Unchanged
New
measurement.qualitative / .text / .absent_reason
Non-numeric results are now typed
New
interpretation.flag / .flag_raw
Normalised flag, plus the report's own wording
New
interpretation.applied_range
The range the flag was judged against
New
biomarker.loinc_code, .panel_id, .panel_key
New
biomarker.region, source.region_name
DEXA: which anatomical site
New
source.collection_date / .collection_time
Per-result, when the report gives them
Reference ranges are bands, not one interval. A result can carry several. HbA1c arrives as <5.7, 5.7–6.4, ≥6.5. Read label for what each band means; do not assume the middle one is "normal". type is a coded version of label and is omitted when the report's wording doesn't map onto the enum.
5. Statuses
Most of the vocabulary carries over unchanged: processing, processed, standardizing, standardized, sending, sent, retry_scheduled, retrying, cancelled, failed and deleted all mean what they did.
Two differences to handle if you branch on status:
partially_sentis new. A report can now be delivered to several destinations independently, so this means some succeeded and some failed. Treat it as terminal, and readGET /v2/reports/{session_id}/deliveriesto see which is which.received,queued,dequeuedanddead_letteredare not part of the new set. If you have a branch on any of them, it becomes dead code.
What does not carry over
Your existing reports stay where they are. History is not copied to the new API. Keep reading it from your own store, or from the dashboard, which shows both.
Reprocess.
POST /v2/reports/{session_id}/reprocessis not yet available. If you rely on reprocessing, re-upload the file for now and speak to your account contact.
Migrating in practice
Enable the capability and confirm a test upload returns
202.Point one non-production environment at
/v2/reports.Update your webhook handler for the envelope: branch on
type, readdata.results[], dedupe onevent_id.Compare one real report through both stacks and diff the output.
Switch production. Nothing needs to happen at once. Both endpoints keep working.
Stuck on a field that doesn't map cleanly? Send us the report and the shape you need; that is usually a catalogue fix on our side, not work on yours.
Last updated
Was this helpful?