Writing and amending a clinical note
Record a visit's clinical narrative as a FHIR R4 Composition, read it back, then amend it without clobbering a concurrent edit. A Composition is the clinical-note projection of an encounter: the same stored visit the Encounter resource renders, exposed as LOINC-coded narrative sections (chief complaint, history, findings, assessment, plan) instead of the visit envelope. One scope carries the flow: system/Composition.cru — create, read, and update, including the conditional PUT that makes amendments safe.
The part most first writes get wrong is the amend. Two clients that both read a note and both PUT it will silently lose one edit unless the second write is rejected. The If-Match header and the weak ETag the API returns on every read close that window — send the version you read, and a stale write fails loudly with a 409 instead of overwriting fresher data.
Audience
You integrate an EHR or a scribe tool that writes clinical narrative back into HuliPractice. You have created an Encounter before, you read a Bundle without a viewer, and you know what a FHIR reference is. You want to take a note from draft to a stored, amendable Composition.
You'll need
- A bearer token from HuliPractice (Practice Settings → Integrations → API Keys), or a SMART Backend Services access token. See Bearer Tokens for provisioning and
POST /auth/tokenfor the token exchange — this recipe assumes you already hold one. - The system/Composition.cru scope on that token. It sits under the Medical records card, which is BAA-gated — the clinic admin must attest to a Business Associate Agreement before a key carrying it can be minted.
.crugrants create, read, and update;system/Composition.rs alone grants read + search. - The
idof the patient the note is for, and theidof the authoring practitioner. Resolve them with a Patient search and a Practitioner search if you only hold names. curl, or Node, Python, Java, or Go if you prefer a language client.
End state
You hold a 201 Created whose body is the stored Composition — with a server-assigned id, the patient as subject, the practitioner as author, the LOINC type 11488-4 (consultation note), and your narrative under section[]. You then read it back, capture its ETag, and land a 200 OK amendment guarded by If-Match.
Steps
1. Export the token and the references
export HULI_TOKEN="<paste your bearer token here>"
export PATIENT_ID="01965e2a-8c4d-7000-9001-0000000000a2"
export PRACTITIONER_ID="01965e2a-8c4d-7000-9001-0000000000c1"
2. Create the note
POST a Composition with status, the patient subject, the practitioner author, and one LOINC-coded section per narrative field. The section code routes the text back to its field, so use the exact LOINC codes below — an unrecognized code is ignored on write.
/fhir/R4/Composition| Section | LOINC code | Maps to |
|---|---|---|
| Chief complaint | 10154-3 | reason for the visit |
| History of present illness | 10164-2 | subjective history |
| Physical findings | 29545-1 | objective findings |
| Assessment | 51848-0 | diagnostic impression |
| Plan of care | 18776-5 | care-plan narrative |
curl -i -X POST https://api.huli.ai/fhir/R4/Composition \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{
"resourceType": "Composition",
"status": "preliminary",
"type": {
"coding": [
{ "system": "http://loinc.org", "code": "11488-4", "display": "Consultation note" }
]
},
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" },
"author": [ { "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1" } ],
"section": [
{
"title": "Chief complaint",
"code": { "coding": [ { "system": "http://loinc.org", "code": "10154-3" } ] },
"text": {
"status": "generated",
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">Cefalea de 3 días.</div>"
}
},
{
"title": "Physical findings",
"code": { "coding": [ { "system": "http://loinc.org", "code": "29545-1" } ] },
"text": {
"status": "generated",
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">TA 120/80. Sin focalización.</div>"
}
},
{
"title": "Plan of care",
"code": { "coding": [ { "system": "http://loinc.org", "code": "18776-5" } ] },
"text": {
"status": "generated",
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">Analgésico y control en 1 semana.</div>"
}
}
]
}'
const section = (code: string, title: string, text: string) => ({
title,
code: { coding: [{ system: 'http://loinc.org', code }] },
text: {
status: 'generated',
div: `<div xmlns="http://www.w3.org/1999/xhtml">${text}</div>`,
},
});
const composition = {
resourceType: 'Composition',
status: 'preliminary', // required — preliminary|final|amended|entered-in-error
type: { coding: [{ system: 'http://loinc.org', code: '11488-4', display: 'Consultation note' }] },
subject: { reference: `Patient/${process.env.PATIENT_ID}` }, // required
author: [{ reference: `Practitioner/${process.env.PRACTITIONER_ID}` }], // required
section: [
section('10154-3', 'Chief complaint', 'Cefalea de 3 días.'),
section('29545-1', 'Physical findings', 'TA 120/80. Sin focalización.'),
section('18776-5', 'Plan of care', 'Analgésico y control en 1 semana.'),
],
};
const resp = await fetch('https://api.huli.ai/fhir/R4/Composition', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
'Content-Type': 'application/fhir+json',
Accept: 'application/fhir+json',
},
body: JSON.stringify(composition),
});
if (resp.status === 201) {
const created = (await resp.json()) as { id: string };
// The weak ETag is your concurrency token for the amend in step 4.
console.log('created', created.id, resp.headers.get('ETag'));
} else {
const outcome = (await resp.json()) as { issue: { diagnostics: string }[] };
console.log(resp.status, outcome.issue[0].diagnostics);
}
import os
import requests
def section(code: str, title: str, text: str) -> dict:
return {
"title": title,
"code": {"coding": [{"system": "http://loinc.org", "code": code}]},
"text": {
"status": "generated",
"div": f'<div xmlns="http://www.w3.org/1999/xhtml">{text}</div>',
},
}
composition = {
"resourceType": "Composition",
"status": "preliminary", # required — preliminary|final|amended|entered-in-error
"type": {"coding": [{"system": "http://loinc.org", "code": "11488-4", "display": "Consultation note"}]},
"subject": {"reference": f"Patient/{os.environ['PATIENT_ID']}"}, # required
"author": [{"reference": f"Practitioner/{os.environ['PRACTITIONER_ID']}"}], # required
"section": [
section("10154-3", "Chief complaint", "Cefalea de 3 días."),
section("29545-1", "Physical findings", "TA 120/80. Sin focalización."),
section("18776-5", "Plan of care", "Analgésico y control en 1 semana."),
],
}
resp = requests.post(
"https://api.huli.ai/fhir/R4/Composition",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
},
json=composition,
timeout=30,
)
if resp.status_code == 201:
# resp.headers["ETag"] is the concurrency token for the amend in step 4.
print("created", resp.json()["id"], resp.headers.get("ETag"))
else:
print(resp.status_code, resp.json()["issue"][0]["diagnostics"])
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class WriteNote {
public static void main(String[] args) throws Exception {
// status/subject/author are required; each section's LOINC code routes its
// narrative to a clinical field. Hand-built JSON keeps this dependency-free;
// a real client would use a JSON library.
String body = "{"
+ "\"resourceType\":\"Composition\","
+ "\"status\":\"preliminary\","
+ "\"type\":{\"coding\":[{\"system\":\"http://loinc.org\",\"code\":\"11488-4\",\"display\":\"Consultation note\"}]},"
+ "\"subject\":{\"reference\":\"Patient/" + System.getenv("PATIENT_ID") + "\"},"
+ "\"author\":[{\"reference\":\"Practitioner/" + System.getenv("PRACTITIONER_ID") + "\"}],"
+ "\"section\":["
+ "{\"code\":{\"coding\":[{\"system\":\"http://loinc.org\",\"code\":\"10154-3\"}]},"
+ "\"text\":{\"status\":\"generated\",\"div\":\"<div xmlns=\\\"http://www.w3.org/1999/xhtml\\\">Cefalea de 3 días.</div>\"}}"
+ "]}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/Composition"))
.header("Authorization", "Bearer " + System.getenv("HULI_TOKEN"))
.header("Content-Type", "application/fhir+json")
.header("Accept", "application/fhir+json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Capture the ETag for the conditional amend in step 4.
System.out.println(response.statusCode());
System.out.println(response.headers().firstValue("ETag").orElse(""));
System.out.println(response.body());
}
}
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
// status/subject/author are required; each section's LOINC code routes its
// narrative to a clinical field (10154-3 chief complaint, 29545-1 findings,
// 18776-5 plan). The diagnosis section is read-only on write.
body := []byte(`{
"resourceType": "Composition",
"status": "preliminary",
"type": {"coding": [{"system": "http://loinc.org", "code": "11488-4", "display": "Consultation note"}]},
"subject": {"reference": "Patient/` + os.Getenv("PATIENT_ID") + `"},
"author": [{"reference": "Practitioner/` + os.Getenv("PRACTITIONER_ID") + `"}],
"section": [
{
"code": {"coding": [{"system": "http://loinc.org", "code": "10154-3"}]},
"text": {"status": "generated", "div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">Cefalea de 3 días.</div>"}
}
]
}`)
req, err := http.NewRequest(http.MethodPost,
"https://api.huli.ai/fhir/R4/Composition", bytes.NewReader(body))
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()
out, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
// resp.Header.Get("ETag") is the concurrency token for the amend in step 4.
fmt.Printf("%d %s\n%s\n", resp.StatusCode, resp.Header.Get("ETag"), out)
}
A 201 Created returns the stored Composition. The response carries a weak ETag header — W/"<versionId>", derived from the visit's last-modified time — and the same value in meta.versionId. Keep it: it is the token the amend in step 4 conditions on.
{
"resourceType": "Composition",
"id": "01965e2a-8c4d-7000-9060-0000000000e9",
"meta": {
"versionId": "1769472764000000000",
"lastUpdated": "2026-06-26T10:12:44.000-06:00",
"profile": ["https://fhir.huli.ai/r4/StructureDefinition/HuliComposition"]
},
"status": "preliminary",
"type": {
"coding": [{ "system": "http://loinc.org", "code": "11488-4", "display": "Consultation note" }]
},
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2", "type": "Patient" },
"encounter": {
"reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9",
"type": "Encounter"
},
"author": [
{ "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1", "type": "Practitioner" }
],
"title": "Clinical note",
"date": "2026-06-26T10:12:44.000-06:00",
"section": [
{
"title": "Chief complaint",
"code": {
"coding": [
{ "system": "http://loinc.org", "code": "10154-3", "display": "Chief complaint" }
]
},
"text": {
"status": "generated",
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">Cefalea de 3 días.</div>"
}
}
]
}
Note the encounter reference: it resolves to the same id as the Composition — proof the two are projections of one visit row.
3. Read the note and capture its version
A single read returns the current note and stamps the weak ETag you condition the amend on. Search instead with patient, date, type, or _id to list a patient's notes.
/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9curl -i "https://api.huli.ai/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
Read the ETag response header — e.g. ETag: W/"1769472764000000000". The type search parameter only matches the consultation-note code (11488-4 or http://loinc.org|11488-4); any other value returns an empty bundle, since every Huli Composition is a consultation note.
4. Amend the note with If-Match
A Composition PUT is a full replace of the narrative: send the complete section set you want stored, not just the changed one — a section you omit is cleared. Set the If-Match header to the ETag you captured so a concurrent edit cannot be lost. The server re-checks the version against the locked row inside the update transaction, so the guard holds even under a race.
/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9curl -i -X PUT https://api.huli.ai/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9 \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-H 'If-Match: W/"1769472764000000000"' \
-d '{
"resourceType": "Composition",
"status": "final",
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" },
"author": [ { "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1" } ],
"section": [
{
"code": { "coding": [ { "system": "http://loinc.org", "code": "10154-3" } ] },
"text": {
"status": "generated",
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">Cefalea de 3 días, ya resuelta.</div>"
}
},
{
"code": { "coding": [ { "system": "http://loinc.org", "code": "18776-5" } ] },
"text": {
"status": "generated",
"div": "<div xmlns=\"http://www.w3.org/1999/xhtml\">Alta. Sin necesidad de control.</div>"
}
}
]
}'
const etag = 'W/"1769472764000000000"'; // captured from the read in step 3
const resp = await fetch(
'https://api.huli.ai/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9',
{
method: 'PUT',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
'Content-Type': 'application/fhir+json',
Accept: 'application/fhir+json',
'If-Match': etag,
},
body: JSON.stringify({
resourceType: 'Composition',
status: 'final', // completes the visit
subject: { reference: `Patient/${process.env.PATIENT_ID}` },
author: [{ reference: `Practitioner/${process.env.PRACTITIONER_ID}` }],
section: [
section('10154-3', 'Chief complaint', 'Cefalea de 3 días, ya resuelta.'),
section('18776-5', 'Plan of care', 'Alta. Sin necesidad de control.'),
],
}),
},
);
if (resp.status === 200) {
console.log('amended', resp.headers.get('ETag')); // a new version
} else if (resp.status === 409) {
// HPB-00103 — someone amended it since you read it. Re-read step 3, reapply, retry.
console.log('stale — re-read and retry');
}
A 200 OK returns the updated Composition with a new versionId/ETag. Setting status to final completes the underlying visit.
What to verify
- The create is
201. 201 Created The responseresourceTypeisCompositionwith a server-assignedid, and theencounterreference resolves to that same id. - The response carries an
ETagheader and a matchingmeta.versionId. - Your
section[]round-trips: each LOINC code you sent comes back, with the narrative insidetext.div. - The amend is
200and itsETag/versionIddiffers from the one you sent inIf-Match. - A re-read after an
entered-in-errororfinalwrite reflects the newstatus.
What can go wrong
All errors return a FHIR OperationOutcome — {severity, code, diagnostics}, no details object. Branch on the HTTP status and issue[0].code; the Huli code is the prefix of issue[0].diagnostics, split on ": ".
HPB-00103 — version conflict. Your If-Match did not match thestored version: the note changed between your read and your PUT. Re-read it (step 3), reapply your edit onto the fresh copy, and retry with the new ETag. Do not strip If-Match to force the write through — that is the exact lost-update you are guarding against.
HPB-00104 — insufficient scope. The token lackssystem/Composition.cru (or .rs for a read). Because the Clinical information cardis BAA-gated, confirm the key was minted with a BAA attestation in Practice Settings.
404 Not FoundHPB-00117 — Composition not found. The id does not name a visitin your organization (or it was never created). Confirm the id and the token's organization.
400 Bad RequestHPB-00101 — validation error. A required field is missing(status, subject, or author on create) or the JSON is malformed. The subject must reference a patient, and author[0] a practitioner, that resolve in your organization.
final cannot be set toentered-in-error through this surface — a finalized visit cannot be voided, mirroring the encounter model. Void a preliminary note instead; for a finalized one, record a correcting note.
A representative 409 body:
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "conflict",
"diagnostics": "HPB-00103: Version conflict"
}
]
}
Next recipes
- Creating a clinical encounter — set the visit envelope and diagnoses (the
Encounterprojection of this same row). - Uploading a document — attach a lab PDF or scanned file to the patient with
DocumentReference.$upload, under the same Clinical information card. - Fetching a patient's full record — pull every Composition, Encounter, Observation, and document for a patient in one
$everythingBundle.