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

system/Encounter.rs — plus plain reads of the Practitioner and

Organization 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.r is read-by-id, s is search; rs grants both. The Practitioner and Organization references inside those resources are read-only resources you resolve with a plain read — no separate write scope exists for them in v1.
  • 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

GET/fhir/R4/Appointment?practitioner=<id>&date=ge2026-06-01&date=le2026-06-30&status=booked&_count=50

Appointment 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

GET/fhir/R4/Encounter?date=ge2026-06-01&date=le2026-06-30&status=finished&_count=50

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

GET/fhir/R4/Encounter/01965e2a-8c4d-7000-9003-0000000000e1
curl "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

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

GET/fhir/R4/Practitioner/01965e2a-8c4d-7000-9001-0000000000c1
curl "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 200 for each search and read. 200 OK
  • For searches, resourceType is Bundle and type is searchset; for a read-by-id, the body is the bare resource (Appointment / Encounter / Practitioner / Organization).
  • total matches the count of entry items you expected for the window.
  • Each appointment carries a Practitioner reference under participant[].actor; each encounter carries one under participant[].individual and an Organization under serviceProvider.
  • Resolving those references with a read-by-id returns 200, confirming your .rs scopes cover the read-only Practitioner and Organization.
  • The cursor walk terminates — the final page has no link[rel=next], and your accumulated count equals total.

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.

401 Unauthorized HPB-00106 — auth failed. The token is missing, malformed, or

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

403 Forbidden HPB-00104 — insufficient scope. The token authenticated but

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

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

404 Not Found HPB-00102 — not found. The id in a read-by-id does not exist

within your organization. Searches never return 404 for an empty result — they return a 200 searchset with total: 0.

429 Too Many Requests HPB-00105 — rate limited. You exceeded the per-key request

budget — 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 at https://api.huli.ai/fhir/.well-known/smart-configuration and verify signatures against https://api.huli.ai/fhir/.well-known/jwks.json.
  • Read Observations for an encounter — add system/Observation.rs and pull vital-signs, laboratory, and exam results scoped to each Encounter you ingested here.