Wire a read-only partner — appointments and encounters
Give a partner that only needs to read — an analytics dashboard, a reporting tool, a referral network — least-privilege access to the FHIR R4 appointment and encounter feed. Search both resources, read a single resource by id, walk a multi-page searchset cursor, and resolve the Practitioner and Organization references those resources point at. Two scopes carry the whole integration — system/Appointment.rs and
Practitioner andOrganization resources those records point at.
This integration only reads. The v1 surface for a read-only partner is Appointment, Encounter, Practitioner, and Organization — request only .rs scopes. You will not POST and you will not PUT: a token minted with only .rs scopes cannot mutate the source system even by accident, which is exactly the posture you want for a feed consumer.
Audience
You build an analytics or reporting product — or a referral network — that ingests an appointment and encounter feed from partner clinics. You have called a FHIR R4 server before, you read a Bundle without a viewer, and you want a read-only pull integration wired against the v1 surface — nothing reaches back and mutates the source system.
You'll need
- An admin bearer token from HuliPractice (Practice Settings → Integrations → API Keys). An admin-role user on the partner organization mints it; the token is shown once, so copy it immediately.
- These two scopes on that token:
- system/Appointment.rs — read plus search on
Appointment. - system/Encounter.rs — read plus search on
Encounter.ris read-by-id,sis search;rsgrants both. ThePractitionerandOrganizationreferences inside those resources are read-only resources you resolve with a plain read — no separate write scope exists for them in v1.
- system/Appointment.rs — read plus search on
curl, or Node, Python, Java, or Go if you prefer a language client.
End state
You hold two searchset Bundles — one of Appointment resources, one of Encounter resources — for Doctora María Fernández's schedule at Clínica San Rafael over a date window. You can read any single resource by id, follow the link[rel=next] cursor across pages, and dereference the Practitioner and Organization each resource points at.
Steps
1. Export the token
export HULI_API_KEY="<paste your admin bearer token here>"
Confirm it is set:
echo $HULI_API_KEY
2. Search appointments over a date window
/fhir/R4/Appointment?practitioner=<id>&date=ge2026-06-01&date=le2026-06-30&status=booked&_count=50Appointment search accepts patient, practitioner, date, and status. The date parameter takes a FHIR prefix (eq, gt, ge, lt, le); pass it twice to bound a window — date=ge2026-06-01 and date=le2026-06-30 for the month of June. Timestamps are ISO-8601 with offset on the wire. The full parameter list per resource lives in the FHIR Implementation Guide.
curl "https://api.huli.ai/fhir/R4/Appointment?practitioner=01965e2a-8c4d-7000-9001-0000000000c1&date=ge2026-06-01&date=le2026-06-30&status=booked&_count=50" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
const params = new URLSearchParams();
params.set('practitioner', '01965e2a-8c4d-7000-9001-0000000000c1');
params.append('date', 'ge2026-06-01');
params.append('date', 'le2026-06-30');
params.set('status', 'booked');
params.set('_count', '50');
const resp = await fetch(`https://api.huli.ai/fhir/R4/Appointment?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HULI_API_KEY}`,
Accept: 'application/fhir+json',
},
});
console.log(resp.status);
console.log(await resp.json());
import os
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/Appointment",
params=[
("practitioner", "01965e2a-8c4d-7000-9001-0000000000c1"),
("date", "ge2026-06-01"),
("date", "le2026-06-30"),
("status", "booked"),
("_count", "50"),
],
headers={
"Authorization": f"Bearer {os.environ['HULI_API_KEY']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
print(resp.status_code)
print(resp.json())
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
public class AppointmentSearch {
public static void main(String[] args) throws Exception {
// A repeated parameter (date) is two key=value pairs joined by &.
StringBuilder query = new StringBuilder();
query.append("practitioner=").append(enc("01965e2a-8c4d-7000-9001-0000000000c1"));
query.append("&date=").append(enc("ge2026-06-01"));
query.append("&date=").append(enc("le2026-06-30"));
query.append("&status=").append(enc("booked"));
query.append("&_count=").append(enc("50"));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/Appointment?" + query))
.header("Authorization", "Bearer " + System.getenv("HULI_API_KEY"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse<String> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
)
func main() {
endpoint, err := url.Parse("https://api.huli.ai/fhir/R4/Appointment")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("practitioner", "01965e2a-8c4d-7000-9001-0000000000c1")
q.Add("date", "ge2026-06-01")
q.Add("date", "le2026-06-30")
q.Set("status", "booked")
q.Set("_count", "50")
endpoint.RawQuery = q.Encode()
req, err := http.NewRequest(http.MethodGet, endpoint.String(), nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HULI_API_KEY"))
req.Header.Set("Accept", "application/fhir+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
// Production code branches on status and decodes the OperationOutcome on the
// error paths. The "What can go wrong" section maps each code.
switch resp.StatusCode {
case http.StatusOK:
fmt.Printf("200 OK\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.StatusForbidden: // HPB-00104 insufficient scope
fmt.Printf("403 forbidden\n%s\n", body)
case http.StatusBadRequest: // HPB-00101 validation error
fmt.Printf("400 bad request\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)
default:
fmt.Printf("%d\n%s\n", resp.StatusCode, body)
}
}
A repeated parameter like date is a list of tuples in Python's requests, params.append in TypeScript's URLSearchParams, two &date= pairs in a hand-built Java query string, and q.Add in Go. Using params.set / q.Set twice overwrites the first value and you lose one bound of the window.
A 200 OK returns a Bundle of type searchset. Resources live under entry[].resource; total is the match count.
{
"resourceType": "Bundle",
"id": "01965e2a-8c4d-7000-9002-0000000000d0",
"meta": {
"lastUpdated": "2026-06-02T08:30:00.000-06:00"
},
"type": "searchset",
"total": 1,
"link": [
{
"relation": "self",
"url": "https://api.huli.ai/fhir/R4/Appointment?practitioner=01965e2a-8c4d-7000-9001-0000000000c1&date=ge2026-06-01&date=le2026-06-30&status=booked&_count=50"
}
],
"entry": [
{
"fullUrl": "https://api.huli.ai/fhir/R4/Appointment/01965e2a-8c4d-7000-9002-0000000000d1",
"resource": {
"resourceType": "Appointment",
"id": "01965e2a-8c4d-7000-9002-0000000000d1",
"meta": {
"versionId": "1",
"lastUpdated": "2026-05-30T11:02:18.000-06:00"
},
"status": "booked",
"start": "2026-06-12T09:00:00.000-06:00",
"end": "2026-06-12T09:30:00.000-06:00",
"participant": [
{
"actor": {
"reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2",
"display": "Ana Lucía Fernández Ramírez"
},
"status": "accepted"
},
{
"actor": {
"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1",
"display": "Doctora María Fernández"
},
"status": "accepted"
}
]
}
}
]
}
3. Search encounters for the same window
/fhir/R4/Encounter?date=ge2026-06-01&date=le2026-06-30&status=finished&_count=50Encounter search accepts patient, date, status, and class. The same date-prefix rule applies. To pull every encounter for one patient, pass patient=<id> instead of (or alongside) the date window.
curl "https://api.huli.ai/fhir/R4/Encounter?date=ge2026-06-01&date=le2026-06-30&status=finished&_count=50" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
A representative Encounter resource inside the searchset:
{
"resourceType": "Encounter",
"id": "01965e2a-8c4d-7000-9003-0000000000e1",
"meta": {
"versionId": "2",
"lastUpdated": "2026-06-12T10:14:55.000-06:00"
},
"status": "finished",
"class": {
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "AMB",
"display": "ambulatory"
},
"subject": {
"reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2",
"display": "Ana Lucía Fernández Ramírez"
},
"participant": [
{
"individual": {
"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1",
"display": "Doctora María Fernández"
}
}
],
"period": {
"start": "2026-06-12T09:02:11.000-06:00",
"end": "2026-06-12T09:41:37.000-06:00"
},
"serviceProvider": {
"reference": "Organization/01965e2a-8c4d-7000-9001-0000000000b0",
"display": "Clínica San Rafael"
}
}
4. Read a single resource by id
When you already hold an id — from a prior search, a webhook, or a referral payload — read it directly instead of searching. The r in .rs grants this.
/fhir/R4/Encounter/01965e2a-8c4d-7000-9003-0000000000e1curl "https://api.huli.ai/fhir/R4/Encounter/01965e2a-8c4d-7000-9003-0000000000e1" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
A read-by-id returns the bare resource (not a Bundle) on 200, or
HPB-00102 if the id does not exist in your organization.5. Resolve the Practitioner and Organization references
Both Appointment and Encounter carry references to a Practitioner (participant[].actor / participant[].individual) and Encounter names an Organization under serviceProvider. Resolve a reference by reading the resource it names. Practitioner and Organization are read-only in v1 — a plain GET is all you get, and all you need.
/fhir/R4/Practitioner/01965e2a-8c4d-7000-9001-0000000000c1curl "https://api.huli.ai/fhir/R4/Practitioner/01965e2a-8c4d-7000-9001-0000000000c1" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
{
"resourceType": "Practitioner",
"id": "01965e2a-8c4d-7000-9001-0000000000c1",
"active": true,
"name": [
{
"use": "official",
"family": "Fernández",
"given": ["María"],
"prefix": ["Dra."]
}
]
}
curl "https://api.huli.ai/fhir/R4/Organization/01965e2a-8c4d-7000-9001-0000000000b0" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
{
"resourceType": "Organization",
"id": "01965e2a-8c4d-7000-9001-0000000000b0",
"active": true,
"name": "Clínica San Rafael"
}
Resolving references with your .rs scopes covers the read of these two resource types — they share the organization scope of your token, so no extra grant is needed. Cache them: the same Practitioner and Organization recur across every appointment and encounter in the feed, so a per-id cache cuts your request volume against the rate limit.
6. Follow the cursor across pages
When a searchset spans more than one _count page, the Bundle carries a link entry with relation: "next" whose url holds an opaque cursor. Follow it verbatim — do not parse, rebuild, or re-sort it. The last page omits the next link.
{
"resourceType": "Bundle",
"type": "searchset",
"total": 138,
"link": [
{
"relation": "self",
"url": "https://api.huli.ai/fhir/R4/Encounter?date=ge2026-06-01&date=le2026-06-30&status=finished&_count=50"
},
{
"relation": "next",
"url": "https://api.huli.ai/fhir/R4/Encounter?date=ge2026-06-01&date=le2026-06-30&status=finished&_count=50&_cursor=eyJ0IjoiMjAyNi0wNi0xMlQwOTo0MTozNy0wNjowMCIsImlkIjoiMDE5NjVlMmEtOGM0ZC03MDAwLTkwMDMtMDAwMDAwMDAwMGUxIn0"
}
],
"entry": []
}
A loop that walks every page and accumulates entries:
const headers = {
Authorization: `Bearer ${process.env.HULI_API_KEY}`,
Accept: 'application/fhir+json',
};
const first = new URLSearchParams();
first.append('date', 'ge2026-06-01');
first.append('date', 'le2026-06-30');
first.set('status', 'finished');
first.set('_count', '50');
let url: string | null = `https://api.huli.ai/fhir/R4/Encounter?${first}`;
const encounters: unknown[] = [];
while (url) {
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`${resp.status} ${await resp.text()}`);
const bundle = await resp.json();
for (const entry of bundle.entry ?? []) encounters.push(entry.resource);
// The next link is fully formed — assign it verbatim.
url = bundle.link?.find((l: { relation: string }) => l.relation === 'next')?.url ?? null;
}
console.log(`pulled ${encounters.length} encounters`);
import os
import requests
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {os.environ['HULI_API_KEY']}",
"Accept": "application/fhir+json",
})
url = "https://api.huli.ai/fhir/R4/Encounter"
params = [
("date", "ge2026-06-01"),
("date", "le2026-06-30"),
("status", "finished"),
("_count", "50"),
]
encounters = []
while url:
resp = session.get(url, params=params, timeout=30)
resp.raise_for_status()
bundle = resp.json()
encounters.extend(e["resource"] for e in bundle.get("entry", []))
# The next link is already fully formed — follow it verbatim, params=None.
url = next(
(l["url"] for l in bundle.get("link", []) if l["relation"] == "next"),
None,
)
params = None
print(f"pulled {len(encounters)} encounters")
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class EncounterWalk {
public static void main(String[] args) throws Exception {
String query = "date=" + enc("ge2026-06-01")
+ "&date=" + enc("le2026-06-30")
+ "&status=" + enc("finished")
+ "&_count=" + enc("50");
HttpClient client = HttpClient.newHttpClient();
String url = "https://api.huli.ai/fhir/R4/Encounter?" + query;
int pulled = 0;
while (url != null) {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + System.getenv("HULI_API_KEY"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse<String> response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException(response.statusCode() + " " + response.body());
}
String body = response.body();
pulled += countMatches(body, "\"resource\"");
// The next link is fully formed — follow it verbatim.
url = nextLink(body);
}
System.out.println("pulled " + pulled + " encounters");
}
private static String nextLink(String body) {
Matcher m = Pattern.compile(
"\\{[^{}]*\"relation\"\\s*:\\s*\"next\"[^{}]*\"url\"\\s*:\\s*\"([^\"]+)\""
+ "|\\{[^{}]*\"url\"\\s*:\\s*\"([^\"]+)\"[^{}]*\"relation\"\\s*:\\s*\"next\"")
.matcher(body);
if (m.find()) {
return m.group(1) != null ? m.group(1) : m.group(2);
}
return null;
}
private static int countMatches(String haystack, String needle) {
int count = 0;
for (int i = haystack.indexOf(needle); i >= 0; i = haystack.indexOf(needle, i + 1)) {
count++;
}
return count;
}
private static String enc(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
}
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
)
type bundle struct {
Link []struct {
Relation string `json:"relation"`
URL string `json:"url"`
} `json:"link"`
Entry []struct {
Resource json.RawMessage `json:"resource"`
} `json:"entry"`
}
func main() {
start, _ := url.Parse("https://api.huli.ai/fhir/R4/Encounter")
q := start.Query()
q.Add("date", "ge2026-06-01")
q.Add("date", "le2026-06-30")
q.Set("status", "finished")
q.Set("_count", "50")
start.RawQuery = q.Encode()
next := start.String()
var encounters []json.RawMessage
for next != "" {
req, _ := http.NewRequest(http.MethodGet, next, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("HULI_API_KEY"))
req.Header.Set("Accept", "application/fhir+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var b bundle
if err := json.Unmarshal(body, &b); err != nil {
panic(err)
}
for _, e := range b.Entry {
encounters = append(encounters, e.Resource)
}
next = ""
for _, l := range b.Link {
if l.Relation == "next" {
next = l.URL // already fully formed; follow verbatim
}
}
}
fmt.Printf("pulled %d encounters\n", len(encounters))
}
What to verify
- HTTP status is
200for each search and read. 200 OK - For searches,
resourceTypeisBundleandtypeissearchset; for a read-by-id, the body is the bare resource (Appointment/Encounter/Practitioner/Organization). totalmatches the count ofentryitems you expected for the window.- Each appointment carries a
Practitionerreference underparticipant[].actor; each encounter carries one underparticipant[].individualand anOrganizationunderserviceProvider. - Resolving those references with a read-by-id returns
200, confirming your.rsscopes cover the read-onlyPractitionerandOrganization. - The cursor walk terminates — the final page has no
link[rel=next], and your accumulated count equalstotal.
What can go wrong
All errors return a FHIR OperationOutcome, never a bare string. Branch on the HTTP status and issue[0].code (the FHIR IssueType) for machine-readable classification. The Huli-specific code (HPB-…) is the prefix of issue[0].diagnostics — split on ": " to extract it. There is no details object, no coding, no text.
HPB-00106 — auth failed. The token is missing, malformed, orrevoked. Confirm the header reads Authorization: Bearer <token> with a single space and that $HULI_API_KEY is exported in this shell. HPB-00107 (auth expired) applies only to the time-limited tokens from SMART Backend Services; if you see it with an admin bearer token, you are sending a SMART access token by mistake.
HPB-00104 — insufficient scope. The token authenticated butlacks the scope for the resource you hit — system/Appointment.rs for Appointment, system/Encounter.rs for Encounter. Re-mint the key in Practice Settings with both scopes selected. You also land here if you try to write: a .rs token has no c or u permission, so a POST or PUT returns 403.
HPB-00101 — validation error. A search parameter is malformed —an unknown parameter, a bad date prefix (use ge / le, not >=), or a non-ISO-8601 timestamp. Check the parameter against the per-resource search reference in the FHIR Implementation Guide.
HPB-00102 — not found. The id in a read-by-id does not existwithin your organization. Searches never return 404 for an empty result — they return a 200 searchset with total: 0.
HPB-00105 — rate limited. You exceeded the per-key requestbudget — common when resolving references without caching. Read the Retry-After response header and back off for that many seconds before retrying.
A representative 403 body:
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
Next recipes
- Paginate large result sets — walk the
link[rel=next]cursor with backoff and resume, the general pattern this recipe applies to the encounter feed. - Authenticate as a SMART Backend Service — swap the admin bearer token for
client_credentials+private_key_jwt(RS384, 5-minute access token) when you ship the feed consumer server-to-server. Discover the endpoints athttps://api.huli.ai/fhir/.well-known/smart-configurationand verify signatures againsthttps://api.huli.ai/fhir/.well-known/jwks.json. - Read Observations for an encounter — add
system/Observation.rsand pull vital-signs, laboratory, and exam results scoped to eachEncounteryou ingested here.