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.
crugrants create, read, and update — a write needs thec. 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.valueandvalueQuantity.unit; the LOINC catalog resolves the canonical unit and display server-side. Send a human unit label (e.g.mg/dL) invalueQuantity.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 —systemexactlyhttp://loinc.organd acodethe observation catalog recognizes. This is required; acodewith no LOINC coding, or an unknown LOINC code, is a400(HPB-00101).- The write reads
valueQuantity.valueandvalueQuantity.unit. The LOINC catalog resolves the canonical unit and display, sovalueQuantity.code(UCUM) is not validated on write — sendsystem/codefor round-trip fidelity if you like, but they do not gate the write. statusis required, and the write surface accepts only a subset of the FHIR value set: a create persistsfinalonly — any other status (registered,preliminary,amended,corrected,cancelled) is rejected with422onObservation.status. To void a stored result,PUTit withentered-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
/fhir/R4/ObservationSave 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 thatidround-trips on the follow-upGET /fhir/R4/Observation/{id}. code.coding[0].systemishttp://loinc.organd the LOINCcoderound-trips unchanged.valueQuantity.valueround-trips andvalueQuantity.systemishttp://unitsofmeasure.org. Theunitreflects the catalog's canonical unit for the LOINC code, which may differ from the label you sent.subject.referenceresolves to yourPATIENT_ID. The body carries noencounter(a standalone write is out-of-encounter), and noreferenceRangeorcomponent(the write surface does not read them).statusisfinal. There is noreferenceRange/componenton 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.
HPB-00101 — validation. The body broke a write rule. Twolab-integration shapes hit this:
- Missing / unknown LOINC (
HPB-02908):codehas no coding underhttp://loinc.org, or the LOINCcodeis 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 otherstatus (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 encounteris read-only on this surface; remove it (see step 1).
404 Not FoundHPB-00102 — not found. An unresolvable subject reference isrejected. 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 butlacks the c in system/Observation.cru. Re-mint it with Observation.cru selected; Observation.rs is read-only and cannot write.
HPB-00106 (auth failed) / HPB-00107 (auth expired). Thetoken is missing, malformed, or — on a SMART Backend Services token — past its 5-minute TTL. For HPB-00107, exchange a fresh access token at
/auth/tokenHPB-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 requestbudget — 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
Observationbypatient+code+date, thenPUTentered-in-errorto 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.