---
title: Send lab results to the chart
description: POST a LOINC-coded, UCUM-quantified Observation from your lab system — link it to the Patient and decode the validation errors that block most first writes.
nav: Recipes
order: 50
version: v1
source: handwritten
updated: 2026-08-13
---

# Send lab results to the chart

Push one result from your lab system into the patient's chart in HuliPractice as a FHIR R4
`Observation` — LOINC-coded, UCUM-quantified, anchored to a `Patient`. The write either
lands a `201 Created` with a server-assigned ID or returns a FHIR `OperationOutcome` you
can map back to your lab system's queue. This recipe covers both ends.

<Callout variant="warning">
Standalone Observation writes are **patient-scoped and out-of-encounter**. `Observation.encounter`
is read-only on the Public API: a create or update that carries it is rejected with `400`.
Encounter-bound results are recorded through the encounter save flow, not as standalone
Observation POSTs — see the note in step 1. The write surface also takes a single
`valueQuantity`; `referenceRange` and `component` are **not read on write** (they are
silently dropped and do not round-trip).
</Callout>

## Audience

You run the interface side of a clinical laboratory in Latin America. You speak HL7 v2 or ASTM on the
analyzer side, you map LOINC to your local test catalog, and you carry UCUM units on
every numeric result. You want the exact FHIR shape Huli accepts on write and the
rejections that account for most first-integration failures.

## You'll need

- A token carrying <Scope name="system/Observation.cru" />. `cru` grants create, read,
  and update — a write needs the `c`. An admin bearer token minted in **Practice
  Settings → Integrations → API Keys** works, as does a SMART Backend Services access
  token (`client_credentials` + `private_key_jwt`, RS384, 5-minute TTL).
- <Scope name="system/Patient.rs" /> on the same token if your lab system resolves the `Patient`
  UUID by search before writing. This recipe assumes you already hold it from the order
  message. (<Scope name="system/Encounter.rs" /> is only needed to _read_ encounter-bound
  observations back — a write must not carry an encounter; see step 1.)
- A LOINC code for every test you post. The lab's analytical result maps to a LOINC
  `code` — Huli validates it against its observation catalog on write.
- A numeric value and its unit. The write reads `valueQuantity.value` and
  `valueQuantity.unit`; the LOINC catalog resolves the canonical unit and display
  server-side. Send a human unit label (e.g. `mg/dL`) in `valueQuantity.unit`.
- `curl`, or a Go, Python, or Node HTTP client.

<Callout variant="info">
LOINC is the `code`; the value is the `valueQuantity`. `code.coding[].system` must be
exactly `http://loinc.org` and the `code` must be one the catalog recognizes — an unknown
LOINC code is a `400` (`HPB-00101`, catalog lookup). The write reads `valueQuantity.value`
and `valueQuantity.unit`; it does **not** validate `valueQuantity.code` (UCUM), so a
missing or mismatched UCUM token does not by itself fail the write. The canonical unit is
resolved from the LOINC catalog regardless of what you send.
</Callout>

## End state

You hold a `201 Created` whose body is the stored `Observation`, now carrying a
server-assigned `id`. The resource references Doctora María Fernández's patient at
Clínica San Rafael, and it round-trips on a follow-up `GET /fhir/R4/Observation/{id}`.

## Steps

### 1. Export the token and the patient reference

```bash
export HULI_TOKEN="<paste your bearer token here>"
export PATIENT_ID="01965e2a-8c4d-7000-9001-0000000000a2"
```

Do **not** put an `Encounter` reference on the write. `Observation.encounter` is read-only
on the Public API — a write that carries it is **rejected with `400`**. Encounter-bound
observations are recorded through the encounter save flow (managed atomically with the
encounter's clinical record), not as standalone Observation POSTs. A standalone lab result
is patient-scoped and out-of-encounter; the server still emits `Observation.encounter` on
`GET`/search for observations that _were_ captured during an encounter.

### 2. Build the Observation body

Write a LOINC-coded serum glucose result of `126 mg/dL` for the patient.

```json
{
  "resourceType": "Observation",
  "status": "final",
  "category": [
    {
      "coding": [
        {
          "system": "http://terminology.hl7.org/CodeSystem/observation-category",
          "code": "laboratory",
          "display": "Laboratory"
        }
      ]
    }
  ],
  "code": {
    "coding": [
      {
        "system": "http://loinc.org",
        "code": "2339-0",
        "display": "Glucose [Mass/volume] in Blood"
      }
    ]
  },
  "subject": {
    "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2"
  },
  "effectiveDateTime": "2026-06-01T07:42:00-06:00",
  "valueQuantity": {
    "value": 126,
    "unit": "mg/dL",
    "system": "http://unitsofmeasure.org",
    "code": "mg/dL"
  }
}
```

Field-level rules the server enforces on this body:

- `code.coding[]` must carry a LOINC entry — `system` exactly `http://loinc.org` and a
  `code` the observation catalog recognizes. This is required; a `code` with no LOINC
  coding, or an unknown LOINC code, is a `400` (`HPB-00101`).
- The write reads `valueQuantity.value` and `valueQuantity.unit`. The LOINC catalog
  resolves the canonical unit and display, so `valueQuantity.code` (UCUM) is not validated
  on write — send `system`/`code` for round-trip fidelity if you like, but they do not
  gate the write.
- `status` is required, and the write surface accepts only a **subset** of the FHIR value
  set: a create persists `final` only — any other status (`registered`, `preliminary`,
  `amended`, `corrected`, `cancelled`) is rejected with `422` on `Observation.status`. To
  void a stored result, `PUT` it with `entered-in-error` (see the next recipe).
- The numeric value is range-checked against the catalog's validation range for that LOINC
  code; an out-of-range value is rejected with `400` (`HPB-02907`).

<Callout variant="warning">
The write surface takes a **single** `valueQuantity` per Observation. `referenceRange` and
`component` are not read by the write decoder — if you send them they are silently dropped
and will not round-trip. Multi-component vitals (e.g. a blood-pressure panel with separate
systolic/diastolic components) are not supported as a single standalone Observation write.
</Callout>

### 3. POST the Observation

<Endpoint method="POST" path="/fhir/R4/Observation" />

Save the body from step 2 to `observation.json`, then:

:::CodeGroup

```bash
curl -i -X POST https://api.huli.ai/fhir/R4/Observation \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Content-Type: application/fhir+json" \
  -H "Accept: application/fhir+json" \
  -d @observation.json
```

```typescript
const UCUM_SYSTEM = 'http://unitsofmeasure.org';

function ucum(value: number, unitCode: string) {
  return { value, unit: unitCode, system: UCUM_SYSTEM, code: unitCode };
}

const unit = 'mg/dL';
const observation = {
  resourceType: 'Observation',
  status: 'final',
  category: [
    {
      coding: [
        {
          system: 'http://terminology.hl7.org/CodeSystem/observation-category',
          code: 'laboratory',
          display: 'Laboratory',
        },
      ],
    },
  ],
  code: {
    coding: [
      {
        system: 'http://loinc.org',
        code: '2339-0',
        display: 'Glucose [Mass/volume] in Blood',
      },
    ],
  },
  subject: { reference: `Patient/${process.env.PATIENT_ID}` },
  effectiveDateTime: '2026-06-01T07:42:00-06:00',
  valueQuantity: ucum(126, unit),
};

const resp = await fetch('https://api.huli.ai/fhir/R4/Observation', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.HULI_TOKEN}`,
    'Content-Type': 'application/fhir+json',
    Accept: 'application/fhir+json',
  },
  body: JSON.stringify(observation),
});

if (resp.status === 201) {
  const created = (await resp.json()) as { id: string };
  console.log(created.id);
} else {
  const outcome = (await resp.json()) as {
    issue: { diagnostics: string }[];
  };
  // HPB code is the prefix of issue[0].diagnostics, split on ': '
  const [hpbCode] = outcome.issue[0].diagnostics.split(': ', 1);
  console.log(resp.status, hpbCode, outcome.issue[0].diagnostics);
}
```

```python
import os
import requests

UCUM_SYSTEM = "http://unitsofmeasure.org"


def ucum(value: float, unit_code: str) -> dict:
    """Build a UCUM-coded quantity in one canonical unit."""
    return {"value": value, "unit": unit_code, "system": UCUM_SYSTEM, "code": unit_code}


unit = "mg/dL"
observation = {
    "resourceType": "Observation",
    "status": "final",
    "category": [
        {
            "coding": [
                {
                    "system": "http://terminology.hl7.org/CodeSystem/observation-category",
                    "code": "laboratory",
                    "display": "Laboratory",
                }
            ]
        }
    ],
    "code": {
        "coding": [
            {
                "system": "http://loinc.org",
                "code": "2339-0",
                "display": "Glucose [Mass/volume] in Blood",
            }
        ]
    },
    "subject": {"reference": f"Patient/{os.environ['PATIENT_ID']}"},
    "effectiveDateTime": "2026-06-01T07:42:00-06:00",
    "valueQuantity": ucum(126, unit),
}

resp = requests.post(
    "https://api.huli.ai/fhir/R4/Observation",
    headers={
        "Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
        "Content-Type": "application/fhir+json",
        "Accept": "application/fhir+json",
    },
    json=observation,
    timeout=30,
)

if resp.status_code == 201:
    print(resp.json()["id"])
else:
    outcome = resp.json()
    # HPB code is the prefix of issue[0].diagnostics, split on ": "
    code = outcome["issue"][0]["diagnostics"].split(": ", 1)[0]
    print(resp.status_code, code, outcome["issue"][0]["diagnostics"])
```

```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class PostObservation {

    static final String UCUM_SYSTEM = "http://unitsofmeasure.org";

    // ucum builds a UCUM-coded quantity. The write reads value + unit; the
    // system/code round-trip but are not validated. Hand-built JSON keeps this
    // dependency-free; a real LIS would use a JSON library.
    static String ucum(double value, String unitCode) {
        return String.format(
            "{\"value\":%s,\"unit\":\"%s\",\"system\":\"%s\",\"code\":\"%s\"}",
            value, unitCode, UCUM_SYSTEM, unitCode);
    }

    public static void main(String[] args) throws Exception {
        String unit = "mg/dL";
        String patientId = System.getenv("PATIENT_ID");

        String observation = "{"
            + "\"resourceType\":\"Observation\","
            + "\"status\":\"final\","
            + "\"category\":[{\"coding\":[{"
            + "\"system\":\"http://terminology.hl7.org/CodeSystem/observation-category\","
            + "\"code\":\"laboratory\",\"display\":\"Laboratory\"}]}],"
            + "\"code\":{\"coding\":[{"
            + "\"system\":\"http://loinc.org\","
            + "\"code\":\"2339-0\",\"display\":\"Glucose [Mass/volume] in Blood\"}]},"
            + "\"subject\":{\"reference\":\"Patient/" + patientId + "\"},"
            + "\"effectiveDateTime\":\"2026-06-01T07:42:00-06:00\","
            + "\"valueQuantity\":" + ucum(126, unit)
            + "}";

        HttpClient client = HttpClient.newHttpClient();
        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create("https://api.huli.ai/fhir/R4/Observation"))
            .header("Authorization", "Bearer " + System.getenv("HULI_TOKEN"))
            .header("Content-Type", "application/fhir+json")
            .header("Accept", "application/fhir+json")
            .POST(HttpRequest.BodyPublishers.ofString(observation))
            .build();

        HttpResponse<String> response =
            client.send(request, HttpResponse.BodyHandlers.ofString());

        switch (response.statusCode()) {
            case 201 -> // ack the lab system's message
                System.out.println("201 created\n" + response.body());
            case 400 -> // HPB-00101 validation — unknown LOINC code, or value out of catalog range
                System.out.println("400 validation\n" + response.body()); // dead-letter, do not retry
            case 409 -> // HPB-00103 conflict — dead-letter like a 400; do not retry blindly
                System.out.println("409 conflict\n" + response.body());
            case 403 -> // HPB-00104 insufficient scope — token lacks Observation.cru
                System.out.println("403 forbidden\n" + response.body());
            case 404 -> // HPB-00102 — subject (Patient) reference does not resolve
                System.out.println("404 not found\n" + response.body());
            case 401 -> // HPB-00106 auth failed / HPB-00107 auth expired
                System.out.println("401 unauthorized\n" + response.body());
            case 429 -> // HPB-00105 rate limited — requeue after the header's seconds
                System.out.println("429 rate limited (Retry-After: "
                    + response.headers().firstValue("Retry-After").orElse("")
                    + ")\n" + response.body());
            default ->
                System.out.println(response.statusCode() + "\n" + response.body());
        }
    }
}
```

```go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
)

type quantity struct {
	Value  float64 `json:"value"`
	Unit   string  `json:"unit"`
	System string  `json:"system"`
	Code   string  `json:"code"`
}

const ucumSystem = "http://unitsofmeasure.org"

// ucum builds a UCUM-coded quantity. The write reads value + unit; the
// system/code round-trip but are not validated on write.
func ucum(value float64, unitCode string) quantity {
	return quantity{Value: value, Unit: unitCode, System: ucumSystem, Code: unitCode}
}

func main() {
	const unit = "mg/dL"
	obs := map[string]any{
		"resourceType": "Observation",
		"status":       "final",
		"category": []any{map[string]any{"coding": []any{map[string]any{
			"system":  "http://terminology.hl7.org/CodeSystem/observation-category",
			"code":    "laboratory",
			"display": "Laboratory",
		}}}},
		"code": map[string]any{"coding": []any{map[string]any{
			"system":  "http://loinc.org",
			"code":    "2339-0",
			"display": "Glucose [Mass/volume] in Blood",
		}}},
		"subject":           map[string]any{"reference": "Patient/" + os.Getenv("PATIENT_ID")},
		"effectiveDateTime": "2026-06-01T07:42:00-06:00",
		"valueQuantity":     ucum(126, unit),
	}

	payload, err := json.Marshal(obs)
	if err != nil {
		panic(err)
	}

	req, err := http.NewRequest(http.MethodPost,
		"https://api.huli.ai/fhir/R4/Observation", bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("HULI_TOKEN"))
	req.Header.Set("Content-Type", "application/fhir+json")
	req.Header.Set("Accept", "application/fhir+json")

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)

	switch resp.StatusCode {
	case http.StatusCreated:
		fmt.Printf("201 created\n%s\n", body) // ack the lab system's message
	case http.StatusBadRequest: // HPB-00101 validation — unknown LOINC code, or value out of catalog range
		fmt.Printf("400 validation\n%s\n", body) // dead-letter, do not retry
	case http.StatusConflict: // HPB-00103 conflict — dead-letter like a 400; do not retry blindly
		fmt.Printf("409 conflict\n%s\n", body)
	case http.StatusForbidden: // HPB-00104 insufficient scope — token lacks Observation.cru
		fmt.Printf("403 forbidden\n%s\n", body)
	case http.StatusNotFound: // HPB-00102 — subject (Patient) reference does not resolve
		fmt.Printf("404 not found\n%s\n", body)
	case http.StatusUnauthorized: // HPB-00106 auth failed / HPB-00107 auth expired
		fmt.Printf("401 unauthorized\n%s\n", body)
	case http.StatusTooManyRequests: // HPB-00105 rate limited
		fmt.Printf("429 rate limited (Retry-After: %s)\n%s\n",
			resp.Header.Get("Retry-After"), body) // requeue after the header's seconds
	default:
		fmt.Printf("%d\n%s\n", resp.StatusCode, body)
	}
}
```

:::

### 4. Confirm the stored resource

The `201` response body is the stored `Observation` with its assigned `id`. Re-read it to
confirm it persisted:

```bash
curl https://api.huli.ai/fhir/R4/Observation/<id-from-201> \
  -H "Authorization: Bearer $HULI_TOKEN" \
  -H "Accept: application/fhir+json"
```

## What to verify

- HTTP status is `201`. <StatusBadge code="201" />
- The response body has a server-assigned `id` — a UUID you did not send — and that `id`
  round-trips on the follow-up `GET /fhir/R4/Observation/{id}`.
- `code.coding[0].system` is `http://loinc.org` and the LOINC `code` round-trips
  unchanged.
- `valueQuantity.value` round-trips and `valueQuantity.system` is
  `http://unitsofmeasure.org`. The `unit` reflects the catalog's canonical unit for the
  LOINC code, which may differ from the label you sent.
- `subject.reference` resolves to your `PATIENT_ID`. The body carries **no** `encounter`
  (a standalone write is out-of-encounter), and no `referenceRange` or `component` (the
  write surface does not read them).
- `status` is `final`. There is no `referenceRange`/`component` on the stored resource for
  a standalone lab write.

## What can go wrong

Every failure returns a FHIR `OperationOutcome` — never a bare string. Branch on the HTTP
status and on `issue[0].code` (the FHIR IssueType) for machine classification; lift the
Huli code (`HPB-…`) from the prefix of `issue[0].diagnostics`, split on `": "`. There is
no `issue.details`, no `coding`, no `text` — only `severity`, `code`, `diagnostics`, and
(on structural field errors such as the `status` 422) an `expression` FHIRPath pointing at
the offending element.

<StatusBadge code="400" /> `HPB-00101` — validation. The body broke a write rule. Two
lab-integration shapes hit this:

- **Missing / unknown LOINC** (`HPB-02908`): `code` has no coding under `http://loinc.org`,
  or the LOINC `code` is not in the observation catalog. Map your local test catalog to a
  valid LOINC before posting.
- **Value out of range** (`HPB-02907`): the numeric value falls outside the catalog's
  validation range for that LOINC code. Confirm the value and that you mapped to the right
  code.

Note: a missing or mismatched UCUM `valueQuantity.code` is **not** a write error — the
write reads `value` + `unit` and the catalog resolves the canonical unit.

A representative `400` body:

```json
{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "invalid",
      "diagnostics": "HPB-00101: code.coding must include a LOINC code (system http://loinc.org)"
    }
  ]
}
```

<StatusBadge code="422" /> — unsupported `status`. A create accepts only `final`; any other
status (`registered`, `preliminary`, `amended`, `corrected`, `cancelled`) is rejected at
`Observation.status`. On an update, only `final` (a value edit) or `entered-in-error` (the
void) are accepted. Post released lab results as `final`.

```json
{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "value",
      "expression": ["Observation.status"],
      "diagnostics": "Observation.status not supported on this operation: create accepts \"final\"; update accepts \"final\" or \"entered-in-error\""
    }
  ]
}
```

<StatusBadge code="400" /> — also: `Observation.encounter` present on a write. The encounter
is read-only on this surface; remove it (see step 1).

<StatusBadge code="404" /> `HPB-00102` — not found. An unresolvable `subject` reference is
rejected. Resolve the patient against `Patient` search before writing, and confirm the
token's organization owns it.

```json
{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "not-found",
      "diagnostics": "HPB-00102: referenced resource not found"
    }
  ]
}
```

<StatusBadge code="403" /> `HPB-00104` — insufficient scope. The token authenticated but
lacks the `c` in <Scope name="system/Observation.cru" />. Re-mint it with
`Observation.cru` selected; `Observation.rs` is read-only and cannot write.

<StatusBadge code="401" /> `HPB-00106` (auth failed) / `HPB-00107` (auth expired). The
token is missing, malformed, or — on a SMART Backend Services token — past its 5-minute
TTL. For `HPB-00107`, exchange a fresh access token at <Endpoint method="POST" path="/auth/token" />
and retry. An admin bearer token does not expire on a timer, so `HPB-00107` against one
usually means a SMART access token is being sent on a request you intended to authenticate
with the admin key.

<StatusBadge code="429" /> `HPB-00105` — rate limited. You exceeded the per-key request
budget — common when a lab system flushes a backlog. Read the `Retry-After` response header and
requeue the message for that many seconds. Do not tight-loop the retry.

## Next recipes

- **Search and void a prior result** — query `Observation` by `patient` + `code` + `date`,
  then `PUT` `entered-in-error` to void a result an analyzer re-run supersedes.
- **Resolve Patient references by search** — turn an order message's identifiers into the
  patient UUID this recipe assumes you already hold.
- **Authenticate as a SMART Backend Service** — swap the admin bearer token for
  `client_credentials` + `private_key_jwt` (RS384) for an unattended lab-system interface.
