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.

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 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).
  • system/Patient.rs on the same token if your lab system resolves the PatientUUID by search before writing. This recipe assumes you already hold it from the order message. (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.

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

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.

{
  "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).

3. POST the Observation

POST/fhir/R4/Observation

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

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
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);
}
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"])
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());
        }
    }
}
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:

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. 201 Created
  • 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.

400 Bad Request 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:

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "invalid",
      "diagnostics": "HPB-00101: code.coding must include a LOINC code (system http://loinc.org)"
    }
  ]
}
422 Unprocessable Entity — 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.

{
  "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\""
    }
  ]
}
400 Bad Request — also: Observation.encounter present on a write. The encounter

is read-only on this surface; remove it (see step 1).

404 Not Found 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.

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "not-found",
      "diagnostics": "HPB-00102: referenced resource not found"
    }
  ]
}
403 Forbidden HPB-00104 — insufficient scope. The token authenticated but

lacks the c in system/Observation.cru. Re-mint it with Observation.cru selected; Observation.rs is read-only and cannot write.

401 Unauthorized 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

POST/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.

429 Too Many Requests 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.