AnpherosAnpheros PlatformDevelopers

How to import laboratory results into a FHIR record

Tutorial: import a lab report as CSV and as HL7 v2 ORU^R01 into FHIR R4 Observations with LOINC codes and reference ranges, handle unmapped markers and resent reports, and trace each result to its laboratory.

This tutorial takes a laboratory report as a CSV file and as an HL7 v2 ORU^R01 message, imports it into a patient's HL7 FHIR R4 record in one request each, and shows what you get back: laboratory Observation resources with LOINC codes, reference ranges and provenance. It also covers the two problems every lab integration runs into: markers that cannot be mapped automatically and reports that get sent twice. Everything runs on the free sandbox with a synthetic patient.

Why lab results are hard to store

A lab report looks simple: a marker, a value, a unit, a reference range, a date. In practice each laboratory names markers its own way ("Hemoglobina glicozilata", "HbA1c", "Glycated haemoglobin"), uses commas or dots for decimals, writes ranges as 4,0-5,6, <5 or >3.5, and resends the same report when something fails. Stored as free text, those results cannot be compared over time, charted, shared with another application or given to an AI model with confidence.

A FHIR record stores each result as an Observation with a LOINC code, a UCUM unit, a value, a reference range and a date, and keeps who sent it in a Provenance resource. The lab connector does that conversion for you.

 lab report (CSV, HL7 v2, JSON)
        │  POST /v1/patients/{id}/labs/import
        ▼
 ┌──────────────────────────────────────────────┐
 │ parse rows → map marker to LOINC → parse     │
 │ value, unit, range → stable id per row       │
 └──────────────────────────────────────────────┘
        │
        ▼
 Observation (category laboratory) + Provenance (author_type import, source system, report id)
        │
        ├─► the patient's app and every app they consented to
        └─► webhooks: resource.created per Observation

Before you start

You need a sandbox key (sk_test_…) from platform.anpheros.com/dashboard (Get a sandbox key). The examples use curl; the SDKs have the same calls (labs.importCsv, labs.importHl7, labs.importItems).

export BASE=https://platform.anpheros.com
export KEY=sk_test_…
# Elena Ionescu, a synthetic patient with type 2 diabetes
export PID=$(curl -s "$BASE/fhir/R4/Patient?identifier=https://anpheros.com/fhir/sid/sandbox-persona|p02" \
  -H "Authorization: Bearer $KEY" | jq -r '.entry[0].resource.id')

Step 1: import a CSV report

This is how a Romanian laboratory might export a report: semicolons, decimal commas, Romanian column names and dates as DD.MM.YYYY. The connector recognises Romanian and English column names.

cat > report.csv <<'EOF'
Analiza;Rezultat;UM;Interval de referinta;Data
Hemoglobina glicozilata (HbA1c);6,9;%;4,0-5,6;01.10.2026
Glicemie;131;mg/dL;70-100;01.10.2026
TSH;2,1;mUI/L;0,4-4,0;01.10.2026
Marker foarte rar;12;U/L;0-20;01.10.2026
EOF

curl -X POST "$BASE/v1/patients/$PID/labs/import" -H "Authorization: Bearer $KEY" \
     -H "Content-Type: text/csv" -H "X-Anpheros-Source-System: labx" \
     -H "Idempotency-Key: report-2026-10-01-elena" --data-binary @report.csv

The response (201):

{"created": 4, "skipped": 0, "unmapped": ["Glicemie", "Marker foarte rar"], "warnings": [],
 "observations": [
  {"id": "62b1b0c0-…", "marker": "Hemoglobina glicozilata (HbA1c)", "code": "4548-4", "system": "http://loinc.org", "value": 6.9, "unit": "%", "date": "2026-10-01"},
  {"id": "12fc61e9-…", "marker": "Glicemie", "code": "glicemie", "system": "urn:anpheros:lab-code", "value": 131.0, "unit": "mg/dL", "date": "2026-10-01"},
  {"id": "fb79fd3e-…", "marker": "TSH", "code": "3016-3", "system": "http://loinc.org", "value": 2.1, "unit": "mUI/L", "date": "2026-10-01"},
  {"id": "1b5ed23c-…", "marker": "Marker foarte rar", "code": "marker_foarte_rar", "system": "urn:anpheros:lab-code", "value": 12.0, "unit": "U/L", "date": "2026-10-01"}]}

HbA1c and TSH were mapped to LOINC from their names. The decimal commas became numbers, and the ranges became referenceRange. Two rows came back in unmapped. They are still stored, under the laboratory's own code (urn:anpheros:lab-code) with the exact text from the report, so nothing is lost.

Step 2: handle unmapped markers

"Glicemie" is unmapped on purpose. It means blood glucose, but LOINC has different codes for glucose in whole blood and glucose in serum or plasma, and the name alone does not say which one the laboratory measured. The connector does not guess. Look the code up:

curl "$BASE/v1/terminology/loinc?q=glucose" -H "Authorization: Bearer $KEY"
{"data": [
  {"system": "http://loinc.org", "code": "2339-0", "display": "Glucose [Mass/volume] in Blood", "display_ro": "Glicemie (sânge)", "category": "laboratory", "unit": "mg/dL"},
  {"system": "http://loinc.org", "code": "2345-7", "display": "Glucose [Mass/volume] in Serum or Plasma", "display_ro": "Glicemie (ser)", "category": "laboratory", "unit": "mg/dL"}],
 "total": 2}

Once you know from the laboratory that it measures serum glucose, send the code in a LOINC column. Keep that mapping per laboratory on your side and add the column to every report from it:

Analiza;Rezultat;UM;Interval de referinta;Data;LOINC
Glicemie;131;mg/dL;70-100;01.10.2026;2345-7
{"created": 1, "skipped": 0, "unmapped": [], "warnings": [],
 "observations": [{"id": "264c997b-…", "marker": "Glicemie", "code": "2345-7", "system": "http://loinc.org", "value": 131.0, "unit": "mg/dL", "date": "2026-10-01"}]}

Markers that LOINC does not cover at all, like the made-up "Marker foarte rar", can stay under the laboratory's code. They are shown to the patient and included in the record. They cannot be compared with other laboratories' results.

Step 3: send the same report twice

Laboratories resend reports. Each row gets a stable identifier built from the report id, the code, the date and the value, so a second import of the same report creates nothing:

{"created": 0, "skipped": 4, "unmapped": ["Glicemie", "Marker foarte rar"], "warnings": [], "observations": []}

Rows that changed, a corrected value for example, are imported as new results. The Idempotency-Key header adds a second guarantee: a network retry with the same key returns the first response unchanged.

Step 4: import an HL7 v2 ORU^R01 message

Most laboratory information systems speak HL7 v2. Send the message as it is (ER7 text, segments separated by CR or LF):

MSH|^~\&|LABX|LAB1|ANPHEROS|EHR|202610010930||ORU^R01|MSG00077|P|2.5.1
PID|1||12345||Ionescu^Elena||19850903|F
OBR|1||R-77|24331-1^Lipid panel^LN|||202610010800
OBX|1|NM|2093-3^Cholesterol [Mass/volume] in Serum or Plasma^LN||212|mg/dL|<200|H|||F|||202610010800
OBX|2|NM|2085-9^HDL Cholesterol^LN||52|mg/dL|>40|N|||F|||202610010800
curl -X POST "$BASE/v1/patients/$PID/labs/import" -H "Authorization: Bearer $KEY" \
     -H "Content-Type: x-application/hl7-v2+er7" --data-binary @oru.hl7
{"created": 2, "skipped": 0, "unmapped": [], "warnings": [],
 "observations": [
  {"id": "b562045b-…", "marker": "Cholesterol [Mass/volume] in Serum or Plasma", "code": "2093-3", "system": "http://loinc.org", "value": 212.0, "unit": "mg/dL", "date": "2026-10-01T08:00:00Z"},
  {"id": "9b3274e4-…", "marker": "HDL Cholesterol", "code": "2085-9", "system": "http://loinc.org", "value": 52.0, "unit": "mg/dL", "date": "2026-10-01T08:00:00Z"}]}

OBX-3 with the LN coding system is used as LOINC directly. OBX-7 becomes the reference range and OBX-8 (H, N) the interpretation. OBR-3 (R-77) is kept as the report id in provenance. The patient is the one in the URL: the PID segment is not used to find a patient, so a wrong PID cannot attach results to someone else.

Already holding parsed results? Send JSON: {"date": "2026-10-01", "origin": "report-77", "items": [{"marker": "Ferritin", "value": 38, "unit": "ng/mL", "ref_low": 15, "ref_high": 150}]}. Ferritin is mapped to LOINC 2276-4.

Step 5: read the results as FHIR

curl "$BASE/fhir/R4/Observation?patient=$PID&code=http://loinc.org|4548-4&_sort=-date&_count=1" \
     -H "Authorization: Bearer $KEY"

The imported HbA1c, as stored:

{
  "resourceType": "Observation",
  "id": "62b1b0c0-…",
  "meta": {"versionId": "1", "lastUpdated": "2026-10-12T20:05:57.011Z"},
  "identifier": [{"system": "urn:anpheros:lab-import", "value": "c52d3cd6449291d34f6bef1516d0ac05"}],
  "status": "final",
  "category": [{"coding": [{"system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory"}]}],
  "code": {"coding": [{"system": "http://loinc.org", "code": "4548-4", "display": "Hemoglobin A1c/Hemoglobin.total in Blood"}],
           "text": "Hemoglobina glicozilata (HbA1c)"},
  "subject": {"reference": "Patient/…"},
  "effectiveDateTime": "2026-10-01",
  "valueQuantity": {"value": 6.9, "unit": "%"},
  "referenceRange": [{"low": {"value": 4.0, "unit": "%"}, "high": {"value": 5.6, "unit": "%"}}]
}

The LOINC display is the standard English name. code.text keeps the laboratory's own wording, so the patient still sees what was on the paper report. For more on resources and search, see the FHIR platform guide and FHIR code systems.

Step 6: check who sent it

curl "$BASE/v1/patients/$PID/provenance/Observation/62b1b0c0-…" -H "Authorization: Bearer $KEY"
{"data": [{"version": 1, "activity": "CREATE", "recorded": "2026-10-12T20:05:57.011000Z",
           "author_type": "import", "author": "proj_…", "source_system": "labx", "origin_id": null,
           "fhir": "Provenance/f15c4fee-…"}], "total": 1}

author_type: import and source_system: labx stay with the value. When an AI application later reads this result through the context API, it is labelled [import/labx], so the model can tell a laboratory result from a value the patient typed in.

What happens next

Frequently asked questions

Which lab report formats can be imported into FHIR?

CSV with a header row (comma, semicolon or tab, decimal comma accepted), HL7 v2 ORU^R01 messages and JSON. PDF reports can be stored as documents; values extracted from them point back to the document.

What happens to markers without a LOINC code?

They are stored under the laboratory's own code with the exact text from the report and listed in unmapped. Ambiguous names, such as a glucose result without the specimen, are not guessed: add a LOINC column for that laboratory and they are mapped.

Is it safe to resend a report?

Yes. Each row has a stable identifier built from the report, code, date and value, so rows already imported are skipped; changed rows are imported as new results.

Can a laboratory import results for any patient?

No. A laboratory imports into patients its project created or whose records the person shared with it through OAuth. Other patients do not exist for it (404).

Related