> For the complete documentation index, see [llms.txt](https://docs.tryterra.co/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.tryterra.co/lab-reports/migrating-from-legacy.md).

# 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.

{% hint style="info" %}
**Not sure which you're on?** If your upload URL starts with `https://api.tryterra.co` you are on the legacy stack. If it starts with `https://access.tryterra.co/api/v2` you are already migrated.
{% endhint %}

## 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_id` to 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.

{% tabs %}
{% tab title="Legacy" %}

```bash
curl -X POST "https://api.tryterra.co/v2/lab-reports" \
  -H "x-api-key: $TERRA_API_KEY" \
  -F "file=@report.pdf"
```

{% endtab %}

{% tab title="New" %}

```bash
curl -X POST "https://access.tryterra.co/api/v2/reports?reference_id=patient_456" \
  -H "x-api-key: $TERRA_API_KEY" \
  -H "dev-id: $TERRA_DEV_ID" \
  -F "file=@report.pdf"
```

{% endtab %}
{% endtabs %}

| Legacy                                             | New                                       |
| -------------------------------------------------- | ----------------------------------------- |
| `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), so `session_id` is 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:

```json
{
  "upload_id": "upl_4a2b8c1d",
  "reference_id": "patient_456",
  "data": [{ "date": "2026-03-01", "time": "09:14", "results": [ … ] }]
}
```

The new webhook is an event envelope. Branch on `type` first:

```json
{
  "type": "lab_report.completed",
  "event_id": "evt_01J9…",
  "occurred_at": "2026-03-01T09:20:11Z",
  "upload_id": "upl_4a2b8c1d",
  "data": {
    "session_id": "297405620317847552",
    "reference_id": "patient_456",
    "report_type": "lab",
    "report_date": "2026-03-01",
    "results_count": 42,
    "results": [ … ],
    "panels": [ … ]
  }
}
```

Three things to handle:

1. **`type`** is `lab_report.completed` or `lab_report.failed`. The legacy stack had no failure event: a failed report simply never arrived. Handle `lab_report.failed` so a bad upload surfaces in your product instead of hanging.
2. **`event_id`** is stable across redeliveries. Use it as your idempotency key.
3. **`data` is an object, not an array.** Where you looped over `data[]`, you now read `data.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.

| Legacy field                               | New location                                           | Notes                                          |
| ------------------------------------------ | ------------------------------------------------------ | ---------------------------------------------- |
| `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         |

{% hint style="warning" %}
**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.
{% endhint %}

## 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_sent` is new.** A report can now be delivered to several destinations independently, so this means some succeeded and some failed. Treat it as terminal, and read `GET /v2/reports/{session_id}/deliveries` to see which is which.
* **`received`, `queued`, `dequeued` and `dead_lettered` are 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}/reprocess` is not yet available. If you rely on reprocessing, re-upload the file for now and speak to your account contact.

## Migrating in practice

1. Enable the capability and confirm a test upload returns `202`.
2. Point one non-production environment at `/v2/reports`.
3. Update your webhook handler for the envelope: branch on `type`, read `data.results[]`, dedupe on `event_id`.
4. Compare one real report through both stacks and diff the output.
5. 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.
