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/token for 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. .cru grants create, read, and update;system/Composition.rs alone grants read + search.
  • The id of the patient the note is for, and the id of 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.

POST/fhir/R4/Composition
SectionLOINC codeMaps to
Chief complaint10154-3reason for the visit
History of present illness10164-2subjective history
Physical findings29545-1objective findings
Assessment51848-0diagnostic impression
Plan of care18776-5care-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.

GET/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9
curl -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.

PUT/fhir/R4/Composition/01965e2a-8c4d-7000-9060-0000000000e9
curl -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 response resourceType is Composition with a server-assigned id, and the encounter reference resolves to that same id.
  • The response carries an ETag header and a matching meta.versionId.
  • Your section[] round-trips: each LOINC code you sent comes back, with the narrative inside text.div.
  • The amend is 200 and its ETag/versionId differs from the one you sent in If-Match.
  • A re-read after an entered-in-error or final write reflects the new status.

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 ": ".

409 Conflict HPB-00103version conflict. Your If-Match did not match the

stored 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.

403 Forbidden HPB-00104insufficient scope. The token lackssystem/Composition.cru (or .rs for a read). Because the Clinical information card

is BAA-gated, confirm the key was minted with a BAA attestation in Practice Settings.

404 Not Found HPB-00117Composition not found. The id does not name a visit

in your organization (or it was never created). Confirm the id and the token's organization.

400 Bad Request HPB-00101validation 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.

422 Unprocessable Entity Illegal status transition. A note already final cannot be set to

entered-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