" \
-d "redirect_uri=https://yourapp.example.com/callback" \
-d "client_id=your-client-id" \
-d "code_verifier=${CODE_VERIFIER}"
```
Successful response:
```json
{
"access_token": "eyJhbGciOiJSUzM4NCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4...",
"scope": "system/Patient.rs system/Appointment.rs"
}
```
Default TTLs: access token 1h, refresh token 30 days.
## Refreshing access tokens
```bash
curl -X POST https://api.huli.ai/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4..." \
-d "client_id=your-client-id"
```
Refresh tokens are single-use (rotation). Each refresh issues a new access token and a
new refresh token. Store the new refresh token immediately — the old one is invalidated.
## PKCE requirements
PKCE is **required** for all public clients. Requests to the authorization endpoint
without `code_challenge` and `code_challenge_method=S256` return an error.
Never use `code_challenge_method=plain`. The `S256` method is enforced.
## State parameter
Always pass `state`. Verify the `state` returned in the redirect matches what you sent.
This prevents CSRF attacks on the redirect.
## Consent screen
The consent screen at `app.huli.ai/oauth/authorize` shows:
- Your application name (set at registration).
- The organization the user is authenticated to.
- The scopes being requested (in plain language).
The user must actively click **Authorize** — there is no auto-approval.
## Token identity
Interactive OAuth tokens carry the identity of the authenticated user. The access token
encodes the user's identity, their `organization_id`, and the granted scopes. Resource
operations are audited against the user, not just the application.
This is the primary distinction from admin bearer tokens and SMART backend services,
which represent the application/key rather than an individual.
## CLI usage
Interactive OAuth is a supported v1 auth mode on the API. The `huli` CLI does not include
an interactive-login subcommand for it — for CLI access use SMART backend services
(`huli auth setup`) or an admin bearer token. See the [CLI reference](/v1/cli) for the
full `huli auth` reference.
========================================================================
# Changelog
# URL: https://developers.huli.ai/v1/changelog
# Release history for the Huli Public API — dated, versioned, and flagged for breaking changes.
# Changelog
## Huli Public API v1.0 — General Availability
**Breaking:** no. This is the initial public release.
### What ships
**FHIR R4 resources**
- `Patient` — read, search, create, update. Identifiers: CURP, RFC, NSS, INE. Two-surname
support via the `segundo-apellido` extension. Gender mapping: FHIR `male/female/other`
→ Huli `M/F/I`.
- `Appointment` — read, search, create, update. Status transitions: `booked`,
`cancelled`, `fulfilled`.
- `Encounter` — read, search, create, update. Class: ambulatory, emergency, inpatient.
- `Observation` — read, search, create, update. Categories: `vital-signs`, `laboratory`,
`exam`. LOINC required on write. UCUM validated.
- `Practitioner` — read, search (read-only).
- `Organization` — read, search (read-only).
**Authentication**
- Admin-managed bearer tokens via Practice Settings.
- SMART backend services (`client_credentials` + `private_key_jwt`, RS384).
- Interactive OAuth Authorization Code + PKCE via `app.huli.ai/oauth/authorize`.
- SMART discovery at `/fhir/.well-known/smart-configuration`.
**Rate limiting and audit**
- Per-key and per-organization request ceilings. `429` with `Retry-After` on limit
exceeded.
- Every authenticated request writes an audit record with 7-year retention (NOM-024
compliance requirement).
**`huli` CLI v1.0**
- `huli auth login` (interactive OAuth PKCE), `huli auth setup` (bearer), `huli auth
status`, `huli auth token`.
- `huli fhir patient|appointment|encounter|observation|practitioner|organization`
subcommands.
- `huli api get|post|put` for raw HTTP access.
- `huli config get|set|list`.
**Scope system**
- `system/.` format. Permissions: `r` (read), `s` (search), `c`
(create), `u` (update).
- Scopes: `system/Patient.rs`, `system/Patient.cru`, `system/Appointment.rs`,
`system/Appointment.cru`, `system/Encounter.rs`, `system/Encounter.cru`,
`system/Observation.rs`, `system/Observation.cru`, `system/Practitioner.rs`,
`system/Organization.rs`.
### Error codes
| Code | HTTP | Meaning |
| ----------- | ---- | --------------------- |
| `HPB-00101` | 400 | Validation error |
| `HPB-00102` | 404 | Resource not found |
| `HPB-00103` | 409 | Version conflict |
| `HPB-00104` | 403 | Insufficient scope |
| `HPB-00105` | 429 | Rate limit exceeded |
| `HPB-00106` | 401 | Authentication failed |
| `HPB-00107` | 401 | Token expired |
### Known limitations at GA
- FHIR `$export` (bulk data access) is not available in v1.
- Webhook subscriptions are not available in v1.
- The `_include` and `_revinclude` FHIR search parameters are not supported.
========================================================================
# CLI
# URL: https://developers.huli.ai/v1/cli
# The huli command-line interface — install, authenticate, and work against the Public FHIR R4 + R5 API.
# Huli CLI
The `huli` CLI wraps the Public FHIR API (both R4 and R5) — authentication, pagination, and output formatting from the command line.
## Install
```bash
brew install hulilabs/tools/huli-cli
```
The installed binary is named `huli`. Confirm it runs:
```bash
huli version
```
## Command groups
```
huli [command]
Available Commands:
fhir FHIR resource operations (patient, appointment, encounter, observation, …)
auth Authentication and token management
config Manage CLI configuration and profiles
version Print the CLI version
Flags:
--help Help for any command
--profile Config profile to use (default: "default")
--output Output format: json | table | yaml (default: "table")
--fhir-version FHIR release to target: r4 | r5 (default: "r4")
```
## FHIR version (R4 / R5)
Every `huli fhir` command works against both FHIR releases. Select the release with the global `--fhir-version` flag (default `r4`); the CLI routes requests to `/fhir/R4` or `/fhir/R5` accordingly:
```bash
# Default — FHIR R4
huli fhir patient get
# Target FHIR R5 (e.g. for recurring appointments)
huli fhir appointment get --fhir-version r5
```
Auth, scopes, and pagination are identical across releases. See [Choosing R4 vs R5](/v1/api/fhir-versions) for which to pick.
Authenticate next — see [Auth](/v1/auth) for bearer tokens, SMART Backend Services, and interactive OAuth.
========================================================================
# Concepts
# URL: https://developers.huli.ai/v1/concepts
# Mental models for working with the Huli Public API — organizations, pagination, rate limiting, and audit.
# Concepts
Three concepts underpin every request to the Huli Public API: the organization boundary
(which organization owns the data), cursor-based pagination (how sets are navigated),
and the rate-limit and audit system (what the API enforces and records on every
authenticated call).
## In this section
- **[Organizations](/v1/concepts/organizations)** — `organization_id` is the security
boundary. Every resource belongs to exactly one organization. Cross-organization reads
return `403`.
- **[Pagination](/v1/concepts/pagination)** — All list endpoints use cursor-based
pagination via `_count` and `_cursor`. `total` is advisory; do not use it as a loop
terminator.
- **[Rate Limiting](/v1/concepts/rate-limiting)** — Per-key and per-organization
request ceilings. Every authenticated request writes an audit record with a 7-year
retention obligation.
- **[Webhooks](/v1/concepts/webhooks)** — Outbound notifications via the FHIR R4
`Subscription` resource. HMAC-signed, id-level, at-least-once deliveries with retries,
auto-pause, and `$replay` for outage recovery.
## FHIR conformance
The public surface is FHIR R4. The normative contract is the live CapabilityStatement
at [`/fhir/R4/metadata`](https://api.huli.ai/fhir/R4/metadata). All resources and
operations advertised there are what the API actually supports — no divergence between
docs and the statement.
Extensions and custom identifiers are documented in the individual resource pages under
the [API reference](/v1/api).
========================================================================
# Organizations
# URL: https://developers.huli.ai/v1/concepts/organizations
# How the organization boundary works in the Huli Public API — every resource belongs to one organization, and every API key is scoped to one organization.
# Organizations
Every resource in the Huli Public API — Patient, Appointment, Encounter, Observation,
Practitioner, Organization — belongs to exactly one organization. The organization is the
security boundary.
## How it works
When you authenticate (via any of the three modes — admin bearer, SMART backend services,
or interactive OAuth), the resulting access token carries an `organization_id` claim. All
resource access is automatically scoped to that organization. There is no way to read or
write across organizations with a single token.
An API key created in Practice Settings is bound to the organization that admin belongs
to. A SMART backend services client assertion is verified against an `api_key` row that
also carries an `organization_id`. An interactive OAuth token carries the organization
the authenticated user belongs to.
## Cross-organization access
Attempting to read a resource that belongs to a different organization returns:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
```
`HPB-00104` covers both missing-scope and cross-organization violations. If you receive
this on a resource you believe your token should reach, verify:
1. The resource ID belongs to your organization.
2. The token was issued for the same organization as the resource.
3. The token includes the scope required for the operation (e.g., `system/Patient.rs`
for reads — see [Scopes](/v1/scopes)).
## Organization identity
Your organization's FHIR `Organization` resource is readable at:
The `id` is the UUID visible in Practice Settings and returned as
`managingOrganization.reference` on Patient resources. It is stable and does not change.
## Implications for integrations
- **Single-organization integrations** (one API key, one organization): the organization
model imposes no additional complexity. Every call works within your org.
- **Multi-organization integrations** (a partner serving multiple Huli organizations): you
need one API key per organization. There is no cross-organization token or admin-level
key that spans organizations. This is by design — it constrains PHI access to explicitly
authorized integrations per org.
- **Audit**: every authenticated request writes an audit record (see
[Rate Limiting](/v1/concepts/rate-limiting) for details). The audit record includes the
`organization_id`, which is the basis for per-org compliance reporting.
========================================================================
# Pagination
# URL: https://developers.huli.ai/v1/concepts/pagination
# Cursor-based pagination on all FHIR list endpoints — how _count, _cursor, and the next link work.
# Pagination
All FHIR search endpoints return a `Bundle` of type `searchset`. Navigation through
large result sets uses cursor-based pagination — not page numbers or offsets.
## Parameters
| Parameter | Type | Default | Max | Notes |
| --------- | ------- | ------- | --- | ------------------------------------------------------ |
| `_count` | integer | 20 | 100 | Number of entries per page |
| `_cursor` | string | — | — | Opaque cursor from the previous response's `next` link |
Request the first page:
```bash
curl "https://api.huli.ai/fhir/R4/Patient?_count=50" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
## Response structure
```json
{
"resourceType": "Bundle",
"type": "searchset",
"total": 847,
"link": [
{
"relation": "self",
"url": "https://api.huli.ai/fhir/R4/Patient?_count=50"
},
{
"relation": "next",
"url": "https://api.huli.ai/fhir/R4/Patient?_count=50&_cursor=eyJ0IjoiMjAyNi0wNi0wMVQxNDozMjowMC4wMDAtMDY6MDAiLCJpZCI6IjAxOTY1ZTJhLThjNGQtNzAwMC05MDAxLTAwMDAwMDAwMDAwMiJ9"
}
],
"entry": [...]
}
```
- **`total`** — the total number of matching resources at the time the first page was
queried. It is advisory. For large sets it may become inaccurate as records are added
or updated while you paginate. Do not use `total` as a loop terminator.
- **`link[relation=next]`** — present when there are more results. Absent on the last
page.
- **`link[relation=self]`** — the canonical URL for the current page.
## Iterating all pages
Pass the full `next` URL as-is to retrieve the next batch. The cursor encodes a
time+UUID position. Do not parse or construct cursor strings manually — the format may
change.
```bash
NEXT_URL="https://api.huli.ai/fhir/R4/Patient?_count=50&_cursor=eyJ0IjoiMj..."
curl "$NEXT_URL" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
Stop when the response has no `link` with `relation: "next"`.
## Stable iteration
The cursor is time+UUID ordered. Pages are stable within a single pagination session —
new records created after the first request will not appear in subsequent pages of that
session, and deleted records will not cause gaps. This makes the API safe for full patient-list
syncs that span multiple pages.
## Combining with filters
Search parameters combine with pagination. All parameters carry forward in the `next`
link — you do not need to re-specify them:
```bash
curl "https://api.huli.ai/fhir/R4/Patient?name=Fernández&active=true&_count=25" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
The `next` link for this response will include `name=Fernández&active=true&_count=25`
alongside the cursor.
## Empty result sets
When no resources match, the response is still a valid `Bundle`:
```json
{
"resourceType": "Bundle",
"type": "searchset",
"total": 0,
"link": [
{
"relation": "self",
"url": "https://api.huli.ai/fhir/R4/Patient?name=DoesNotExist"
}
],
"entry": []
}
```
`entry` is an empty array. There is no `next` link.
========================================================================
# Rate Limiting
# URL: https://developers.huli.ai/v1/concepts/rate-limiting
# Per-key and per-organization rate limits, the Retry-After header, and the audit log written on every authenticated request.
# Rate Limiting
The Huli Public API enforces rate limits at two levels: per API key and per organization.
Every authenticated request also writes an immutable audit record.
## Limits
| Ceiling | Scope | Reset window |
| ----------------------------------- | ---------------------------------------------- | ----------------------- |
| Per-key request rate | Configurable per API key (default: 60 req/min) | 1-minute sliding window |
| Per-org request rate | Shared across all keys in the organization | 1-minute sliding window |
| Token issuance (`POST /auth/token`) | 20 requests/minute per source IP | 1-minute fixed window |
The per-key default and the per-org ceiling are set when the API key is provisioned in
Practice Settings. Contact your organization admin to review or raise the limits for your
key.
## Response headers
Every response includes rate-limit headers:
```http
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1748808720
```
- `X-RateLimit-Limit` — the current limit for this key (requests per minute).
- `X-RateLimit-Remaining` — requests remaining in the current window.
- `X-RateLimit-Reset` — Unix timestamp (UTC) when the window resets.
## When the limit is exceeded
with error code `HPB-00105`:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "throttled",
"diagnostics": "HPB-00105: Rate limit exceeded"
}
]
}
```
The response also includes a `Retry-After` header with the number of seconds to wait:
```http
Retry-After: 14
```
Respect this header. Retry immediately after a `429` without the `Retry-After` delay
results in another `429` and increases the reset time.
## Recommended retry strategy
For transient failures (`429` and `5xx`):
1. Read `Retry-After` on `429` responses. Wait exactly that many seconds.
2. For `5xx` responses: exponential backoff starting at 1s, cap at 60s, jitter ±10%.
3. Do not retry `4xx` responses other than `429` — they indicate a request error that
retrying will not fix.
Never auto-retry `401`, `403`, or `404`.
## Audit log
Every authenticated request to the API writes an audit record. This is a NOM-024
compliance requirement (Mexico), not an optional feature.
Each record captures:
- The FHIR resource type and resource ID involved (e.g., `Patient` and its UUID).
- The API key identifier and the organization the request was made for.
- The query parameters used (for search operations).
- The number of resources returned.
- The client IP address.
- The request correlation ID — also returned in the `X-Correlation-Id` response header.
- A timestamp (ISO-8601 with UTC offset).
**Retention:** audit records are retained for 7 years. They are immutable once written.
**Read access:** audit records are not accessible via the public API in v1. Access is
available to authorized Huli personnel for compliance audits.
**PHI in audit records:** the recorded search parameters may contain patient identifiers
(e.g., `identifier=https://www.gob.mx/curp|FEME800614MDFRRR09`). Audit records are stored
with the same access controls as the primary data.
========================================================================
# Webhooks
# URL: https://developers.huli.ai/v1/concepts/webhooks
# Outbound webhooks via the FHIR R4 Subscription resource — create a rest-hook subscription, verify HMAC-signed deliveries, and recover missed events with replay.
# Webhooks
Outbound webhooks let Huli push a notification to your endpoint whenever a resource
changes — a new Appointment, a finalized Encounter, a lab Observation — instead of you
polling the FHIR API. They are configured entirely through the standard FHIR R4
`Subscription` resource: there is no Huli-native webhook object to learn.
The model is deliberately small. You register a `Subscription` that names a resource type
and an HTTPS endpoint; Huli signs and POSTs a compact FHIR `Bundle` to that endpoint on
every matching event; your receiver verifies the signature, dedupes on an event id, and
GETs the full resource. Deliveries are at-least-once, retried on failure, and recoverable
after an outage via `$replay`.
## Creating a subscription
The request body is a FHIR `Subscription`. A minimal one:
```json
{
"resourceType": "Subscription",
"status": "requested",
"reason": "Sync finalized encounters into our EHR",
"criteria": "Encounter",
"channel": {
"type": "rest-hook",
"endpoint": "https://hooks.example.com/huli",
"payload": "application/fhir+json",
"header": ["X-Source-System: clinica-san-rafael"]
}
}
```
| Field | Required | Notes |
| ------------------ | -------- | ------------------------------------------------------------------------------------------------------ |
| `status` | yes | Send `requested`; the server activates it and returns `active`. |
| `reason` | yes | Non-empty free text describing the subscription. Not persisted — for your own audit trail on the call. |
| `criteria` | yes | The resource **type** to notify on, e.g. `"Encounter"`. Resource-type only in v2 (see below). |
| `channel.type` | yes | Must be `"rest-hook"`. No other channel type is supported. |
| `channel.endpoint` | yes | HTTPS-only target URL. SSRF-checked at dial time — private, link-local, and metadata IPs are refused. |
| `channel.payload` | yes | Must be `"application/fhir+json"`. |
| `channel.header` | no | Extra HTTP headers to send on every delivery, each as a `"Name: Value"` string. |
`criteria` is the resource type only. A FHIR query string like `Encounter?status=finished`
is **rejected** in v2 — filter on the event type in your receiver instead (every delivery
carries an `X-Huli-Event-Type` header).
### The signing secret is returned exactly once
A successful create returns with the stored `Subscription`
**plus the signing secret in an extension**, and a `Cache-Control: no-store` response
header. The secret is shown this one time and is never returned again by any subsequent
read.
Capture the signing secret from the `201` response immediately and store it in your
secrets manager. There is no endpoint that re-reveals it — losing it means deleting the
subscription and creating a new one.
The response also carries a non-blocking Business Associate Agreement (BAA) reminder as a
`contained` `OperationOutcome` with `severity: information`. It does not affect the
`201` — it is informational only.
### Credential requirements
Creating a subscription requires a **machine (API key) credential** carrying the
system/Subscription scope family, and that credential must have been minted
with a BAA attestation. Interactive user tokens cannot create subscriptions.
You can only subscribe to what you can read: creating a subscription (or retargeting its
`criteria` with a PUT) also requires a **read grant on the criteria resource type** — for
example, `criteria: "Encounter"` needs system/Encounter.rs on the same
credential. A create or update without it is refused with `403 HPB-00104`.
## Lifecycle
A subscription moves through four states:
| `status` | Meaning |
| ----------- | ----------------------------------------------------------------------------------------------- |
| `requested` | The state you POST. The server transitions it to `active`. |
| `active` | Deliveries flow. This is the only state that receives events (and the only state that replays). |
| `error` | Auto-paused by the circuit breaker after repeated delivery failures. Reactivate with a PUT. |
| `off` | Revoked (deleted). No further deliveries. |
Manage the subscription with the standard FHIR interactions:
To resume an auto-paused subscription, `PUT` it back with `status: "active"` once your
endpoint is healthy again.
## Scopes
| Scope | Grants |
| --------------------------------------- | ------------------------------------------------------------------------------ |
| system/Subscription.crud | Create, update, and delete subscriptions. |
| system/Subscription.rs | Read and search subscriptions, plus the `$stats` and `$deliveries` operations. |
Replay (`$replay`) mutates delivery state and requires system/Subscription.crud .
Create and update additionally require a read scope (`.rs`) on the criteria resource type —
see "Credential requirements" above.
## Events
In v2, lifecycle events are emitted for these resource types:
| Resource | Events |
| ----------------- | --------------------------- |
| Appointment | created, updated, cancelled |
| Encounter | created, updated, finalized |
| Patient | created, updated |
| Observation | created |
| MedicationRequest | created, updated, cancelled |
| ServiceRequest | created, updated, completed |
The event-type string combines the resource and the transition, e.g.
`Encounter.finished`. It arrives on every delivery in the `X-Huli-Event-Type` header, so
you can route or filter without parsing the body.
## The delivery payload
Each delivery is an HTTP `POST` of a FHIR `Bundle` of `type: "history"` with a single
`entry`. The entry's `request.method` encodes the change:
- `POST` — the resource was **created**
- `PUT` — the resource was **updated**
- `DELETE` — the resource was **deleted or cancelled**
Deliveries are **id-level notifications**, not full snapshots. A create/update carries only
a minimal stub — `{ "resourceType": ..., "id": ... }` — and a delete carries only the
reference. Your receiver then GETs the full, current resource from the FHIR API. This keeps
payloads small and avoids shipping stale copies of PHI.
```json
{
"resourceType": "Bundle",
"type": "history",
"entry": [
{
"resource": { "resourceType": "Encounter", "id": "01965e2a-8c4d-7000-9001-000000000042" },
"request": { "method": "PUT", "url": "Encounter/01965e2a-8c4d-7000-9001-000000000042" }
}
]
}
```
## Headers on every delivery
Every delivery POST carries these headers:
| Header | Value |
| -------------------- | -------------------------------------------------------------------------------------- |
| `X-Huli-Signature` | `sha256=` + lowercase hex of `HMAC-SHA256(signing_secret, raw_request_body)`. |
| `X-Huli-Event-Id` | Stable per event **across retries and replays**. Dedupe on this. |
| `X-Huli-Delivery-Id` | Fresh per attempt — a retry or replay gets a new one. Use it to correlate one attempt. |
| `X-Huli-Event-Type` | The event type, e.g. `Encounter.finished`. |
| `X-Huli-Occurred-At` | RFC3339 timestamp of the source event. |
| `X-Huli-Replay` | `true` only on replay deliveries. Absent otherwise. |
| `Content-Type` | `application/fhir+json`. |
Huli-owned headers always win. If a `channel.header[]` entry collides with any of the
`X-Huli-*` headers or `Content-Type`, the Huli value is sent — your custom header is only
honoured for names Huli does not set.
## Verifying the signature
Compute `HMAC-SHA256(signing_secret, rawBody)`, hex-encode it (lowercase), prefix
`sha256=`, and constant-time-compare the result against the `X-Huli-Signature` header.
Verify against the **raw received body bytes**. Do not parse and re-serialize the JSON
first — any whitespace or key-ordering change alters the bytes and the HMAC will not match.
Read the body as raw bytes before your JSON framework touches it.
:::CodeGroup
```javascript
import crypto from 'node:crypto';
// `rawBody` MUST be the exact bytes received (a Buffer/string), not a re-serialized object.
function verifyHuliSignature(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```
```python
import hashlib
import hmac
def verify_huli_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
# raw_body MUST be the exact bytes received, not a re-serialized dict.
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
return hmac.compare_digest(expected, signature_header or "")
```
:::
Reject any delivery whose signature does not verify — respond with a `4xx` and drop it.
## Delivery semantics
- **At-least-once.** The same event may arrive more than once. Dedupe on `X-Huli-Event-Id`,
which is stable across retries and replays.
- **HTTPS only.** Endpoints must be `https://`. The target is SSRF-checked at dial time.
- **Success is `2xx`.** Any `2xx` response marks the delivery delivered.
- **Retries with backoff.** `5xx` responses and network errors are retried with exponential
backoff, up to 5 attempts.
- **Dead-letter.** A delivery that exhausts its attempts moves to a dead-letter state
(visible in `$deliveries` and counted in `$stats`).
- **Auto-pause.** After 100 consecutive failures the subscription is auto-paused
(`status: error`). Reactivate it with a `PUT` setting `status` back to `active`.
- **Rate-limited per subscription.** Deliveries to a single subscription are throttled so a
large backlog cannot flood your endpoint.
- **Redirects are refused.** A `3xx` response is **not** followed; it counts as a failure.
## Replay — recovering from an outage
If your endpoint was down, re-enqueue the events you missed instead of losing them.
The body is a FHIR `Parameters` with a `from` (`valueInstant`, required) and an optional
`to` (`valueInstant`, defaults to now). You may also pass `from`/`to` as RFC3339 query
parameters.
```json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "from", "valueInstant": "2026-07-01T00:00:00Z" },
{ "name": "to", "valueInstant": "2026-07-02T00:00:00Z" }
]
}
```
Replay re-scans your organization's event history for the subscription's **criteria**
(resource type) over the window and re-enqueues each matching event. This includes events
that occurred **before the subscription was created** and events that were **already
delivered** — replay is a re-scan of the outbox by criteria + window, so treat it as a
backfill, not only a "redeliver what I missed" operation. Your event-id dedup (below)
absorbs any overlap. Constraints:
- The window is clamped to the **30-day retention horizon** — events older than that are
gone.
- A single call re-enqueues **at most 500 events**. For a larger backlog, just call again —
the same window is fine: events with an in-flight replay are skipped, so each call
advances to the next-older events. Repeat until `truncated` comes back `false`.
- Only an **`active`** subscription may replay.
- Repeated calls over the same window are **safe**: an event that already has an in-flight
(undelivered) replay for this subscription is skipped, so you won't pile duplicates.
Replayed deliveries carry `X-Huli-Replay: true` and the **original** `X-Huli-Event-Id`, so
your existing dedupe logic transparently absorbs any overlap with events that did get
through. The call returns a `Parameters` summary:
```json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "deliveriesQueued", "valueInteger": 87 },
{ "name": "truncated", "valueBoolean": false }
]
}
```
## Observability
### Metrics — `$stats`
Returns a `Parameters` with aggregate delivery health:
| Parameter | Meaning |
| ------------------- | -------------------------------------------------- |
| `delivered` | Deliveries that succeeded. |
| `failed` | Deliveries that failed (all attempts counted). |
| `pending` | Deliveries queued but not yet terminal. |
| `dead` | Deliveries that exhausted retries (dead-lettered). |
| `deadLetterDepth` | Current depth of the dead-letter backlog. |
| `totalAttempts` | Total delivery attempts, including retries. |
| `successRate` | Fraction of deliveries that succeeded. |
| `latencyP50Seconds` | Median delivery latency, seconds. |
| `latencyP95Seconds` | 95th-percentile delivery latency, seconds. |
### Delivery trail — `$deliveries`
Returns a `Parameters` with one `delivery` group per recent attempt. Each group carries:
`id`, `status`, `attempts`, `replay`, `event`, `resourceType`, `eventType`, `occurredAt`,
`queuedAt`, and — when set — `lastStatusCode` and `deliveredAt`.
`$deliveries` is a delivery **ledger**, not a payload store. It never exposes the delivery
body, your endpoint URL, or the signing secret — only the metadata needed to debug
delivery health.
========================================================================
# Error Codes
# URL: https://developers.huli.ai/v1/errors
# Complete reference for all error codes returned by the Huli Public FHIR API.
# Error Codes
All error responses use the FHIR [OperationOutcome](https://hl7.org/fhir/R4/operationoutcome.html) format. Each `issue` entry has `severity`, `code` (FHIR IssueType), and `diagnostics`; errors carrying a Huli catalog code also emit it as structured `details.coding` (`system` + `code`, e.g. `"HPB-00101"`). **Branch on the HTTP status and `issue[0].code`** — those are always present and authoritative. Catalog-coded errors additionally prefix `diagnostics` with the Huli code, in the form `"HPB-XXXXX: "`, so you can extract the code by splitting on `": "`. Treat the prefix and `details.coding` as **best-effort / when-present**: some FHIR handlers write an `OperationOutcome` directly with a plain-text `diagnostics`, no HPB prefix, and no `details`, so do not assume every response carries them.
> This page is generated from the canonical Huli Public API error catalog — do not edit it directly.
## OperationOutcome format
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"diagnostics": "HPB-00101: Validation error"
}
]
}
```
## HPB errors (Public FHIR API)
| Code | HTTP Status | Message |
|------|-------------|---------|
| [HPB-00101](#hpb-00101) | 400 | Validation error |
| [HPB-00102](#hpb-00102) | 404 | Resource not found |
| [HPB-00103](#hpb-00103) | 409 | Version conflict |
| [HPB-00104](#hpb-00104) | 403 | Insufficient scope |
| [HPB-00105](#hpb-00105) | 429 | Rate limit exceeded |
| [HPB-00106](#hpb-00106) | 401 | Authentication failed |
| [HPB-00107](#hpb-00107) | 401 | Authentication failed |
| [HPB-00108](#hpb-00108) | 409 | Patient has been merged into another record and cannot accept new clinical data |
| [HPB-00109](#hpb-00109) | 409 | Patient is marked as deceased and cannot accept new clinical data |
| [HPB-00110](#hpb-00110) | 401 | Inbound webhook authentication failed |
| [HPB-00122](#hpb-00122) | 413 | Inbound webhook body exceeds the maximum allowed size |
| [HPB-00111](#hpb-00111) | 404 | MedicationRequest not found |
| [HPB-00112](#hpb-00112) | 404 | ServiceRequest not found |
| [HPB-00113](#hpb-00113) | 400 | ServiceRequest must contain at least one item |
| [HPB-00114](#hpb-00114) | 409 | Signed or cancelled prescription cannot be modified |
| [HPB-00115](#hpb-00115) | 422 | This service offers multiple specialties; a specialty must be selected to book |
| [HPB-00116](#hpb-00116) | 422 | The selected specialty is not offered by this service |
| [HPB-00117](#hpb-00117) | 404 | Composition not found |
| [HPB-00118](#hpb-00118) | 404 | DocumentReference not found |
| [HPB-00119](#hpb-00119) | 413 | Document exceeds the maximum allowed size |
| [HPB-00120](#hpb-00120) | 400 | Document content is invalid or its declared type does not match |
| [HPB-00121](#hpb-00121) | 429 | Sandbox key volume cap exceeded |
| [HPB-00123](#hpb-00123) | 409 | A finalized clinical note cannot be voided |
| [HPB-00124](#hpb-00124) | 403 | Production credentials cannot be used from a browser origin; use a sandbox key |
| [HPB-00135](#hpb-00135) | 409 | Signed or cancelled service order cannot be modified |
| [HPB-00136](#hpb-00136) | 409 | Multi-item service orders are read-only on this surface |
| [HPB-00137](#hpb-00137) | 422 | Appointment status transition is not allowed |
| [HPB-00138](#hpb-00138) | 409 | Appointment already has an active encounter |
| [HPB-00139](#hpb-00139) | 422 | Encounter date is outside the allowed registration window |
| [HPB-00140](#hpb-00140) | 422 | Cannot modify a medication request in entered-in-error |
| [HPB-00141](#hpb-00141) | 403 | Select a normativa-configured clinic before starting a consultation |
| [HPB-00142](#hpb-00142) | 422 | Complete the patient's required normativa data before starting a consultation |
| [HPB-00143](#hpb-00143) | 422 | Cannot update an observation in entered-in-error |
| [HPB-00144](#hpb-00144) | 422 | Observation.subject cannot be changed on update |
| [HPB-00145](#hpb-00145) | 400 | Invalid CURP format |
| [HPB-00146](#hpb-00146) | 400 | CURP does not match the entered data |
| [HPB-00147](#hpb-00147) | 422 | Mexican organizations must select every place from the Mexican national locality catalog instead of sending free-typed place values |
| [HPB-00148](#hpb-00148) | 403 | Organization is blocked |
| [HPB-00149](#hpb-00149) | 422 | Surname \ |
### HPB-00101
**HTTP 400** — Validation error
**Recovery:** Check the request body against the [FHIR R4 resource schema](https://hl7.org/fhir/R4/). Ensure `resourceType` is present and all required fields are provided.
### HPB-00102
**HTTP 404** — Resource not found
**Recovery:** The resource ID does not exist in your organization. Verify the UUID is correct and belongs to your organization.
### HPB-00103
**HTTP 409** — Version conflict
**Recovery:** Version conflict on update. Re-fetch the resource, apply your changes, and retry. Include the current `meta.versionId` in your request.
### HPB-00104
**HTTP 403** — Insufficient scope
**Recovery:** Your access token does not include the required scope for this operation. Request a new token with the correct scope — see [Scopes](/v1/scopes).
### HPB-00105
**HTTP 429** — Rate limit exceeded
**Recovery:** You have exceeded a rate limit. Token issuance is capped at 20 requests per minute per IP on `/auth/token`; resource requests use a separate per-key ceiling (default 60 requests per minute). Read the `Retry-After` header for the exact delay, then retry.
### HPB-00106
**HTTP 401** — Authentication failed
**Recovery:** Authentication failed. Verify your client assertion JWT: check `iss`, `sub`, `aud`, `exp`, and signature. Ensure your JWKS endpoint is reachable.
### HPB-00107
**HTTP 401** — Authentication failed
**Recovery:** Your access token has expired (5-minute TTL). Request a new token via `POST /auth/token`.
### HPB-00108
**HTTP 409** — Patient has been merged into another record and cannot accept new clinical data
### HPB-00109
**HTTP 409** — Patient is marked as deceased and cannot accept new clinical data
### HPB-00110
**HTTP 401** — Inbound webhook authentication failed
### HPB-00122
**HTTP 413** — Inbound webhook body exceeds the maximum allowed size
### HPB-00111
**HTTP 404** — MedicationRequest not found
### HPB-00112
**HTTP 404** — ServiceRequest not found
### HPB-00113
**HTTP 400** — ServiceRequest must contain at least one item
### HPB-00114
**HTTP 409** — Signed or cancelled prescription cannot be modified
### HPB-00115
**HTTP 422** — This service offers multiple specialties; a specialty must be selected to book
### HPB-00116
**HTTP 422** — The selected specialty is not offered by this service
### HPB-00117
**HTTP 404** — Composition not found
### HPB-00118
**HTTP 404** — DocumentReference not found
### HPB-00119
**HTTP 413** — Document exceeds the maximum allowed size
### HPB-00120
**HTTP 400** — Document content is invalid or its declared type does not match
### HPB-00121
**HTTP 429** — Sandbox key volume cap exceeded
### HPB-00123
**HTTP 409** — A finalized clinical note cannot be voided
### HPB-00124
**HTTP 403** — Production credentials cannot be used from a browser origin; use a sandbox key
### HPB-00135
**HTTP 409** — Signed or cancelled service order cannot be modified
### HPB-00136
**HTTP 409** — Multi-item service orders are read-only on this surface
### HPB-00137
**HTTP 422** — Appointment status transition is not allowed
### HPB-00138
**HTTP 409** — Appointment already has an active encounter
### HPB-00139
**HTTP 422** — Encounter date is outside the allowed registration window
### HPB-00140
**HTTP 422** — Cannot modify a medication request in entered-in-error
### HPB-00141
**HTTP 403** — Select a normativa-configured clinic before starting a consultation
### HPB-00142
**HTTP 422** — Complete the patient's required normativa data before starting a consultation
### HPB-00143
**HTTP 422** — Cannot update an observation in entered-in-error
### HPB-00144
**HTTP 422** — Observation.subject cannot be changed on update
### HPB-00145
**HTTP 400** — Invalid CURP format
### HPB-00146
**HTTP 400** — CURP does not match the entered data
### HPB-00147
**HTTP 422** — Mexican organizations must select every place from the Mexican national locality catalog instead of sending free-typed place values
### HPB-00148
**HTTP 403** — Organization is blocked
### HPB-00149
**HTTP 422** — Surname \
## HULI errors (generic)
These generic error codes are shared across all Huli APIs and may appear in responses when a common platform-level condition is triggered.
| Code | HTTP Status | Message |
|------|-------------|---------|
| [HULI-00001](#huli-00001) | 500 | Internal server error |
| [HULI-00002](#huli-00002) | 404 | Resource not found |
| [HULI-00003](#huli-00003) | 400 | Bad request |
| [HULI-00004](#huli-00004) | 401 | Unauthorized |
| [HULI-00005](#huli-00005) | 403 | Forbidden |
| [HULI-00007](#huli-00007) | 503 | Service unavailable |
### HULI-00001
**HTTP 500** — Internal server error
**Recovery:** An unexpected server error occurred. Retry with exponential backoff. If the problem persists, contact support with the request ID from the response.
### HULI-00002
**HTTP 404** — Resource not found
### HULI-00003
**HTTP 400** — Bad request
### HULI-00004
**HTTP 401** — Unauthorized
**Recovery:** No valid Bearer token was provided. Include `Authorization: Bearer ` on your request.
### HULI-00005
**HTTP 403** — Forbidden
### HULI-00007
**HTTP 503** — Service unavailable
**Recovery:** The service is temporarily unavailable. Retry after a short delay.
========================================================================
# Recipes
# URL: https://developers.huli.ai/v1/recipes
# End-to-end integration workflows for the Huli Public API — each one ships a working result against the v1 FHIR R4 surface.
# Recipes
Opinionated, end-to-end workflows. Each recipe ships a working result against the v1
surface — bearer auth, the four read-write FHIR R4 resources (Patient, Appointment,
Encounter, Observation), and the `huli` CLI. Code samples are shown in cURL,
TypeScript, Python, Java, and Go; every one runs as-is.
## What do you want to build?
## v1 recipes
- **[Sandbox quickstart](/v1/recipes/sandbox-quickstart)** — get a sandbox organization pre-seeded with fake FHIR data from a Huli org admin, receive the bearer credential through a one-time share link, and make your first call. Five minutes from link to a `200`.
- **[Run your first authenticated Patient search](/v1/recipes/getting-started-patient-search)** — bearer token + `system/Patient.rs`, the canonical first request. Five minutes from key to `searchset`.
- **[Registering a patient](/v1/recipes/registering-a-patient)** — discover the NOM-024 / MX address codes via the terminology ValueSets, then `POST` a Patient with CURP/RFC identifiers and the second-lastname extension using `system/Patient.cru`.
- **[Booking an appointment end-to-end](/v1/recipes/booking-an-appointment)** — discover a service, practitioner, room, and free slot with `system/Appointment.cru` + `system/Practitioner.rs`, then `POST` the Appointment past its booking preconditions — including the `specialty` selection a multi-specialty service requires.
- **[Scheduling an administrative meeting](/v1/recipes/scheduling-an-administrative-meeting)** — book an internal meeting with no patient: a required title, optional all-day flag, and external email invitees, against a service whose appointment type is `administrative`.
- **[Creating a clinical encounter](/v1/recipes/creating-an-encounter)** — discover the practitioner participant, then `POST` an Encounter for an existing patient with `system/Encounter.cru` + `system/Patient.rs` + `system/Practitioner.rs`.
- **[Writing and amending a clinical note](/v1/recipes/writing-a-clinical-note)** — create the LOINC-sectioned `Composition` projection of a visit and amend it with `If-Match` optimistic concurrency, using the BAA-gated `system/Composition.cru`.
- **[Uploading a document](/v1/recipes/uploading-a-document)** — attach a PDF, scan, or image as a `DocumentReference` (multipart `$upload` or inline base64) and read it back via a 30-minute signed URL, using the BAA-gated `system/DocumentReference.cru`.
- **[Fetching a patient's full record](/v1/recipes/fetching-a-patient-record)** — pull a patient's encounters, observations, notes, documents, medications, and orders in one scope-filtered `Patient/$everything` Bundle.
- **[Wire a read-only partner](/v1/recipes/wiring-a-read-only-partner)** — consume Appointment + Encounter read-only with `system/Appointment.rs` + `system/Encounter.rs`, resolving the Practitioner/Organization references they point at.
- **[Sync a daily patient list with the huli CLI](/v1/recipes/daily-roster-sync-cli)** — cron-safe, restart-idempotent Patient + Appointment pagination driven by the CLI.
- **[Send lab results to the chart](/v1/recipes/posting-lab-observations-lis)** — create Observations with required LOINC codes and UCUM units, with reference-range handling.
- **[Creating and sharing an API key as a clinic admin](/v1/recipes/creating-and-sharing-an-api-key)** — the Practice Settings flow to mint, scope, reveal, share, and rotate an admin bearer key.
- **[Choosing a CLI authentication path](/v1/recipes/cli-authentication-paths)** — interactive OAuth vs M2M (`huli auth setup`) vs a one-off bearer token; when to use which.
- **[Debugging a failed FHIR search](/v1/recipes/debugging-a-failed-fhir-search)** — read the `OperationOutcome`, map the common failures, and trace a correlation id to its audit record.
- **[Receive webhooks](/v1/recipes/receiving-webhooks)** — register a `rest-hook` Subscription with `system/Subscription.crud`, verify the HMAC signature on every delivery, dedupe on the event id, and recover missed events with `$replay`.
========================================================================
# Booking an appointment end-to-end
# URL: https://developers.huli.ai/v1/recipes/booking-an-appointment
# Discover a bookable service, a practitioner and their room, a free slot, then POST a FHIR R4 Appointment — with the booking preconditions the discovery steps exist to satisfy.
# Booking an appointment end-to-end
Turn a "who is free, and for what?" question into a stored `Appointment`. You will discover a
bookable service, find a practitioner and the room they work in, locate a free slot, and
`POST` the booking — assembling exactly the four references the create call needs to pass its
preconditions. Two scopes carry the whole flow: system/Appointment.cru for the
write (and the discovery resources gated behind it) and system/Practitioner.rs
for the practitioner wiring.
A blind `POST /fhir/R4/Appointment` rarely succeeds on the first try: the server rejects a
booking whose `serviceType` is unknown, whose practitioner carries no location, or whose room
sits outside the practitioner's assigned rooms. The discovery steps below exist precisely to
hand you values that satisfy each of those checks, so the final write goes through.
## Audience
You build a patient-booking flow — a portal, a referral intake, or a front-desk tool — and
you have already run [your first authenticated search](/v1/recipes/getting-started-patient-search).
You read a `Bundle` without a viewer, you know what a FHIR reference is, and you want to take
a booking from discovery to a `201 Created`.
## You'll need
- A bearer token from HuliPractice (**Practice Settings → Integrations → API Keys**), or a
SMART Backend Services access token. See [Bearer Tokens](/v1/auth/bearer) for provisioning
and [`POST /auth/token`](/v1/auth) for the token exchange — this recipe assumes you already
hold one.
- These two scopes on that token:
- — create `Appointment` (`.cru` also grants read +
search). The discovery resources `HealthcareService`, `Location`, `Schedule`, and `Slot`
are gated behind the Appointment scope, so this one grant covers them.
- — read + search `Practitioner` and
`PractitionerRole`.
- `curl`, or Node, Python, Java, or Go if you prefer a language client.
The discovery resources page on offset pagination (`_count` + `_offset`), not the keyset
`_cursor` that Patient and Appointment search use. `_count` defaults to 20 and caps at 100;
walk pages by adding `_offset` in multiples of `_count`. The `Slot` and `Schedule` searches
follow the same offset rule.
## End state
You hold a `201 Created` whose body is the stored `Appointment` — booked for a real
practitioner, in a room that practitioner actually works in, at a slot that was free, for a
service the organization offers. Along the way you have the four values the create call
consumed: the `org-service` serviceType coding, the `Practitioner` reference, the room
`Location` reference, and the slot's `start`/`end`.
## Steps
### 1. Export the token
```bash
export HULI_TOKEN=""
```
### 2. Discover a bookable service
Each org service-catalog entry is one `HealthcareService`. The value you need is the entry's
`type.coding` — its `code` is the org-service UUID, published under the `org-service`
CodeSystem. **That coding is exactly what you put in `Appointment.serviceType`** when you
book; copy it verbatim, do not rebuild it from the name.
:::CodeGroup
```bash
curl "https://api.huli.ai/fhir/R4/HealthcareService?_count=50" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```typescript
const resp = await fetch('https://api.huli.ai/fhir/R4/HealthcareService?_count=50', {
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
Accept: 'application/fhir+json',
},
});
const bundle = await resp.json();
// The serviceType coding you book with is entry.resource.type[0].coding[0].
const service = bundle.entry?.[0]?.resource;
const serviceType = service?.type?.[0];
console.log(JSON.stringify(serviceType, null, 2));
```
```python
import os
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/HealthcareService",
params={"_count": 50},
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
bundle = resp.json()
# The serviceType coding you book with is entry.resource.type[0].
service = bundle["entry"][0]["resource"]
service_type = service["type"][0]
print(service_type)
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class DiscoverService {
public static void main(String[] args) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/HealthcareService?_count=50"))
.header("Authorization", "Bearer " + System.getenv("HULI_TOKEN"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// entry[].resource.type[0].coding[0] carries the org-service code you
// place in Appointment.serviceType. Parse with a JSON library in real code.
System.out.println(response.body());
}
}
```
```go
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
req, err := http.NewRequest(http.MethodGet,
"https://api.huli.ai/fhir/R4/HealthcareService?_count=50", nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HULI_TOKEN"))
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)
}
// entry[].resource.type[0].coding[0] holds the org-service serviceType code.
fmt.Printf("%s\n", body)
}
```
:::
A representative `HealthcareService` entry inside the `searchset`:
```json
{
"resourceType": "HealthcareService",
"id": "01965e2a-8c4d-7000-9010-0000000000f1",
"active": true,
"name": "Consulta general",
"type": [
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000f1",
"display": "Consulta general"
}
],
"text": "Consulta general"
}
],
"specialty": [
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/specialty",
"code": "01965e2a-8c4d-7000-9011-0000000000d1",
"display": "Medicina general"
},
{
"system": "http://snomed.info/sct",
"code": "394814009",
"display": "General practice"
}
],
"text": "Medicina general"
},
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/specialty",
"code": "01965e2a-8c4d-7000-9011-0000000000d2",
"display": "Pediatría"
},
{
"system": "http://snomed.info/sct",
"code": "394537008",
"display": "Pediatrics"
}
],
"text": "Pediatría"
}
],
"providedBy": {
"reference": "Organization/01965e2a-8c4d-7000-9001-0000000000b0"
},
"location": [
{
"reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1",
"display": "Consultorio 1"
}
]
}
```
Two fields beyond `type` matter for booking. `specialty[]` lists **every** specialty the
service is offered for — one `CodeableConcept` per specialty. Each one carries a **bookable**
coding under the `specialty` CodeSystem (`system` =
`https://fhir.huli.ai/r4/CodeSystem/specialty`, `code` = the specialty UUID) plus, when the
catalog has one, a human SNOMED coding. The example above offers two specialties (general
medicine and pediatrics), so it is a **multi-specialty service**: when you book it you MUST pick
one and send its `specialty` coding verbatim in `Appointment.specialty` (step 6) — omitting the
selection is rejected (see **What can go wrong**). A service with a single specialty, or an empty
`specialty[]` (offered for all specialties), derives the specialty server-side and needs no
selection. Separately, every practitioner you book must carry the chosen specialty in their
`PractitionerRole.specialty`. The `location[]` array lists the rooms the service is offered in —
useful coverage context, but the _authoritative_ room set for the booking is the practitioner's,
which you read next.
**Shortcut — let the service hand you the valid practitioner/room pairs.** Rather than guessing
which practitioner works in which room, search `Schedule` by the service you just discovered:
every schedule configured to deliver that service binds a practitioner (its `PractitionerRole`
actor) to the room they serve it in (its `Location` actor). Each returned schedule is therefore a
practitioner/room **combination the booking will accept** — pick one and you sidestep the
"practitioner has no location" and "room outside the practitioner's rooms" rejections.
```bash
curl "https://api.huli.ai/fhir/R4/Schedule?service-type=01965e2a-8c4d-7000-9010-0000000000f1" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
The `service-type` value is the org-service `code` from `HealthcareService.type[0].coding[0]`
(step 2) — `service-type` is the FHIR-standard Schedule search parameter that targets
`Schedule.serviceType`. Each entry's `Schedule.actor` carries a `PractitionerRole/` and a
`Location/`; lift that pair and jump straight to step 5 to find a free `Slot` for the
schedule. A schedule with an empty service set serves every service and is returned for any
`service-type` query that names a live org service (an unknown service id returns an empty
bundle). This axis needs only `system/Appointment.rs`; steps 3–4 below are the longer
practitioner-first path (they also read `Practitioner`/`PractitionerRole`, so they additionally
need `system/Practitioner.rs`). Use whichever fits your flow.
### 3. Find a practitioner and their wiring
Search for the practitioner, then read their `PractitionerRole` — that role names the rooms
the practitioner works in and the specialties they carry.
```bash
curl "https://api.huli.ai/fhir/R4/Practitioner?name=Fern%C3%A1ndez" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
The Run button above searches without a name filter (`_count=1`) so it resolves to *some*
practitioner in your sandbox rather than requiring one literally named "Fernández" — swap in
`?name=…` once you know who you're booking.
With the practitioner's id in hand, read their role wiring:
```bash
curl "https://api.huli.ai/fhir/R4/PractitionerRole?practitioner=01965e2a-8c4d-7000-9001-0000000000c1" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
A `PractitionerRole` resource:
```json
{
"resourceType": "PractitionerRole",
"id": "01965e2a-8c4d-7000-9030-0000000000b1",
"active": true,
"practitioner": {
"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"
},
"organization": {
"reference": "Organization/01965e2a-8c4d-7000-9001-0000000000b0"
},
"location": [
{
"reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1",
"display": "Consultorio 1"
}
],
"specialty": [
{
"coding": [
{
"system": "http://snomed.info/sct",
"code": "394814009",
"display": "General practice"
}
],
"text": "Medicina general"
}
]
}
```
Three things to lift from the role: the `location[]` (the rooms this practitioner works in —
pick one in step 4), the `specialty[]` (it must include the service's specialty if step 2
carried one), and the `PractitionerRole.id` — that id is the schedulable resource you query
slots against in step 5.
**Book against the `PractitionerRole.id`, not the `Practitioner` it points at.** When you POST the
Appointment (step 6), `participant[].actor.reference` carries the **`PractitionerRole.id`** you just
discovered, under a `Practitioner/` reference. The practitioner-user id that
`PractitionerRole.practitioner` references is **not** a schedulable resource; booking against it
returns `404`. The reference *type* is `Practitioner` (FHIR conformance), but the *id-space* is the
practitioner-role / schedulable resource — the same id `Schedule` and `Slot` reference.
### 4. Pick a room
Pick a room from `PractitionerRole.location`, and read it back to confirm it is active.
```bash
curl "https://api.huli.ai/fhir/R4/Location/01965e2a-8c4d-7000-9020-0000000000a1" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```json
{
"resourceType": "Location",
"id": "01965e2a-8c4d-7000-9020-0000000000a1",
"status": "active",
"name": "Consultorio 1",
"managingOrganization": {
"reference": "Organization/01965e2a-8c4d-7000-9001-0000000000b0"
}
}
```
Because this room came from the practitioner's own `PractitionerRole.location`, it is
guaranteed to be inside their assigned-location set — which is the set the booking guard
checks. A room taken only from `HealthcareService.location` is not guaranteed to be, and can
be rejected at write time.
### 5. Find a free slot
Slots are computed on the fly from the schedulable resource's schedules minus its booked
appointments, so every slot you get back has `status: "free"`. Search either by `schedule` or
directly by `actor` (the `PractitionerRole.id` from step 3). The `start`/`end` window is
capped at 31 days.
To find the schedule first:
Or skip straight to slots by actor:
:::CodeGroup
```bash
curl "https://api.huli.ai/fhir/R4/Slot?actor=01965e2a-8c4d-7000-9030-0000000000b1" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```typescript
const params = new URLSearchParams();
params.set('actor', '01965e2a-8c4d-7000-9030-0000000000b1');
const resp = await fetch(`https://api.huli.ai/fhir/R4/Slot?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
Accept: 'application/fhir+json',
},
});
const bundle = await resp.json();
const slot = bundle.entry?.[0]?.resource;
// Carry slot.start and slot.end into the Appointment you POST next.
console.log(slot?.start, slot?.end);
```
```python
import os
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/Slot",
params={
"actor": "01965e2a-8c4d-7000-9030-0000000000b1",
},
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
bundle = resp.json()
slot = bundle["entry"][0]["resource"]
# Carry slot["start"] and slot["end"] into the Appointment you POST next.
print(slot["start"], slot["end"])
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class FindSlot {
public static void main(String[] args) throws Exception {
String query = "actor=01965e2a-8c4d-7000-9030-0000000000b1";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/Slot?" + query))
.header("Authorization", "Bearer " + System.getenv("HULI_TOKEN"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// entry[0].resource.start / .end feed the Appointment you POST next.
System.out.println(response.body());
}
}
```
```go
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
)
func main() {
endpoint, err := url.Parse("https://api.huli.ai/fhir/R4/Slot")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("actor", "01965e2a-8c4d-7000-9030-0000000000b1")
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_TOKEN"))
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)
}
// entry[0].resource.start / .end feed the Appointment you POST next.
fmt.Printf("%s\n", body)
}
```
:::
The Run button above sends no `start`/`end`, so the API applies its default window — now through
the next 7 days — which is what most integrations want. Every sandbox practitioner is seeded with
a Mon–Fri 09:00–17:00 schedule valid for a year, so a fresh sandbox always returns free slots
here. If you pass explicit dates instead, keep the span at 31 days or less (the cap above) and in
the schedule's validity window.
A `Slot` inside the `searchset`:
```json
{
"resourceType": "Slot",
"id": "01965e2a-8c4d-7000-9040-0000000000c2",
"schedule": {
"reference": "Schedule/01965e2a-8c4d-7000-9035-0000000000d3"
},
"status": "free",
"start": "2026-06-17T09:00:00.000-06:00",
"end": "2026-06-17T09:30:00.000-06:00"
}
```
Carry that slot's `start` and `end` into the booking.
### 6. POST the Appointment
Assemble the four discovered values into the create body: the `serviceType` coding from step
2 (verbatim), the slot's `start`/`end` from step 5, and a `participant` array naming the
practitioner and the room `Location`. Add the patient participant for a patient-facing
booking.
The body below is the **comprehensive** form — every field the create decoder honors on an
Appointment write. Required fields are flagged inline. The **Full field reference** after the
example is precise about which fields the create decoder reads into
the stored appointment versus which are server-derived from the chosen service — read it before
assuming a field round-trips. A minimal write needs `status`, `start`, `end`, a `serviceType`,
at least one practitioner participant, and a room `Location` participant — plus a `specialty`
selection when the chosen service is multi-specialty (the example includes one).
:::CodeGroup
```bash
curl -i -X POST https://api.huli.ai/fhir/R4/Appointment \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{
"resourceType": "Appointment",
"status": "booked",
"priority": 5,
"description": "Consulta general — control",
"patientInstruction": "Llegar 10 minutos antes y traer estudios previos.",
"serviceType": [
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000f1",
"display": "Consulta general"
}
]
}
],
"specialty": [
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/specialty",
"code": "01965e2a-8c4d-7000-9011-0000000000d1",
"display": "Medicina general"
}
]
}
],
"start": "2026-06-17T09:00:00.000-06:00",
"end": "2026-06-17T09:30:00.000-06:00",
"participant": [
{
"actor": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" },
"status": "accepted"
},
{
"actor": { "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1" },
"status": "accepted"
},
{
"actor": { "reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1" },
"status": "accepted"
}
],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-insurance-snapshot",
"extension": [
{ "url": "provider", "valueString": "Seguros Monterrey" },
{ "url": "policyNumber", "valueString": "POL-99812" },
{ "url": "certificateNumber", "valueString": "CERT-44120" }
]
}
]
}'
```
```typescript
const serviceType = {
coding: [
{
system: 'https://fhir.huli.ai/r4/CodeSystem/org-service',
code: '01965e2a-8c4d-7000-9010-0000000000f1',
display: 'Consulta general',
},
],
};
const appointment = {
resourceType: 'Appointment',
status: 'booked', // required
priority: 5, // optional — uint; the decoder stores it verbatim
description: 'Consulta general — control', // optional — stored
patientInstruction: 'Llegar 10 minutos antes y traer estudios previos.', // optional — stored
serviceType: [serviceType], // required — verbatim from HealthcareService.type; resolves to the org service
specialty: [
// required ONLY when the chosen service offers ≥2 specialties; the bookable
// coding is verbatim from HealthcareService.specialty[].coding (specialty CodeSystem).
// A single-/all-specialty service derives it server-side — omit it then.
{
coding: [
{
system: 'https://fhir.huli.ai/r4/CodeSystem/specialty',
code: '01965e2a-8c4d-7000-9011-0000000000d1',
display: 'Medicina general',
},
],
},
],
start: '2026-06-17T09:00:00.000-06:00', // required — from the free slot
end: '2026-06-17T09:30:00.000-06:00', // required
participant: [
// All three actors are decoded and persisted as the appointment's participants:
// the patient (optional), the practitioner (≥1 required), and the room Location (required).
// Equipment is optional via a Device/ actor.
{ actor: { reference: 'Patient/01965e2a-8c4d-7000-9001-0000000000a2' }, status: 'accepted' },
{
actor: { reference: 'Practitioner/01965e2a-8c4d-7000-9001-0000000000c1' },
status: 'accepted',
},
{ actor: { reference: 'Location/01965e2a-8c4d-7000-9020-0000000000a1' }, status: 'accepted' },
],
extension: [
{
// insurance snapshot — provider required within the block; policy/certificate optional
url: 'https://fhir.huli.ai/r4/StructureDefinition/huli-insurance-snapshot',
extension: [
{ url: 'provider', valueString: 'Seguros Monterrey' },
{ url: 'policyNumber', valueString: 'POL-99812' },
{ url: 'certificateNumber', valueString: 'CERT-44120' },
],
},
],
};
const resp = await fetch('https://api.huli.ai/fhir/R4/Appointment', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
'Content-Type': 'application/fhir+json',
Accept: 'application/fhir+json',
},
body: JSON.stringify(appointment),
});
if (resp.status === 201) {
const created = (await resp.json()) as { id: string };
console.log('booked', created.id);
} else {
const outcome = (await resp.json()) as { issue: { diagnostics: string }[] };
// The HP-/HPB- code is the prefix of issue[0].diagnostics — split on ': '.
const [code] = outcome.issue[0].diagnostics.split(': ', 1);
console.log(resp.status, code, outcome.issue[0].diagnostics);
}
```
```python
import os
import requests
service_type = {
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000f1",
"display": "Consulta general",
}
]
}
appointment = {
"resourceType": "Appointment",
"status": "booked", # required
"priority": 5, # optional — uint; stored verbatim
"description": "Consulta general — control", # optional — stored
"patientInstruction": "Llegar 10 minutos antes y traer estudios previos.", # optional — stored
"serviceType": [service_type], # required — verbatim from HealthcareService.type; resolves to the org service
"specialty": [
# required ONLY when the chosen service offers ≥2 specialties; the bookable
# coding is verbatim from HealthcareService.specialty[].coding (specialty CodeSystem).
# A single-/all-specialty service derives it server-side — omit it then.
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/specialty",
"code": "01965e2a-8c4d-7000-9011-0000000000d1",
"display": "Medicina general",
}
]
}
],
"start": "2026-06-17T09:00:00.000-06:00", # required — from the free slot
"end": "2026-06-17T09:30:00.000-06:00", # required
"participant": [
# All three actors are decoded and persisted as the appointment's participants:
# the patient (optional), the practitioner (≥1 required), and the room Location (required).
# Equipment is optional via a Device/ actor.
{"actor": {"reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2"}, "status": "accepted"},
{"actor": {"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"}, "status": "accepted"},
{"actor": {"reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1"}, "status": "accepted"},
],
"extension": [
{
# insurance snapshot — provider required within the block; policy/certificate optional
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-insurance-snapshot",
"extension": [
{"url": "provider", "valueString": "Seguros Monterrey"},
{"url": "policyNumber", "valueString": "POL-99812"},
{"url": "certificateNumber", "valueString": "CERT-44120"},
],
}
],
}
resp = requests.post(
"https://api.huli.ai/fhir/R4/Appointment",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
},
json=appointment,
timeout=30,
)
if resp.status_code == 201:
print("booked", resp.json()["id"])
else:
outcome = resp.json()
# The HP-/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"])
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class BookAppointment {
public static void main(String[] args) throws Exception {
// serviceType is the org-service coding from HealthcareService, verbatim.
// start/end come from the free slot. Hand-built JSON keeps this
// dependency-free; a real client would use a JSON library.
// status/start/end and serviceType are required; priority/description/
// patientInstruction and the insurance-snapshot extension are optional
// fields the decoder stores. The patient, practitioner (≥1 required) and room
// Location (required) participants are all decoded and persisted.
String appointment = "{"
+ "\"resourceType\":\"Appointment\","
+ "\"status\":\"booked\","
+ "\"priority\":5,"
+ "\"description\":\"Consulta general — control\","
+ "\"patientInstruction\":\"Llegar 10 minutos antes y traer estudios previos.\","
+ "\"serviceType\":[{\"coding\":[{"
+ "\"system\":\"https://fhir.huli.ai/r4/CodeSystem/org-service\","
+ "\"code\":\"01965e2a-8c4d-7000-9010-0000000000f1\","
+ "\"display\":\"Consulta general\"}]}],"
// specialty is required only for a multi-specialty service.
+ "\"specialty\":[{\"coding\":[{"
+ "\"system\":\"https://fhir.huli.ai/r4/CodeSystem/specialty\","
+ "\"code\":\"01965e2a-8c4d-7000-9011-0000000000d1\","
+ "\"display\":\"Medicina general\"}]}],"
+ "\"start\":\"2026-06-17T09:00:00.000-06:00\","
+ "\"end\":\"2026-06-17T09:30:00.000-06:00\","
+ "\"participant\":["
+ "{\"actor\":{\"reference\":\"Patient/01965e2a-8c4d-7000-9001-0000000000a2\"},\"status\":\"accepted\"},"
+ "{\"actor\":{\"reference\":\"Practitioner/01965e2a-8c4d-7000-9001-0000000000c1\"},\"status\":\"accepted\"},"
+ "{\"actor\":{\"reference\":\"Location/01965e2a-8c4d-7000-9020-0000000000a1\"},\"status\":\"accepted\"}"
+ "],"
+ "\"extension\":[{"
+ "\"url\":\"https://fhir.huli.ai/r4/StructureDefinition/huli-insurance-snapshot\","
+ "\"extension\":["
+ "{\"url\":\"provider\",\"valueString\":\"Seguros Monterrey\"},"
+ "{\"url\":\"policyNumber\",\"valueString\":\"POL-99812\"},"
+ "{\"url\":\"certificateNumber\",\"valueString\":\"CERT-44120\"}"
+ "]}]}";
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/Appointment"))
.header("Authorization", "Bearer " + System.getenv("HULI_TOKEN"))
.header("Content-Type", "application/fhir+json")
.header("Accept", "application/fhir+json")
.POST(HttpRequest.BodyPublishers.ofString(appointment))
.build();
HttpResponse response =
client.send(request, HttpResponse.BodyHandlers.ofString());
switch (response.statusCode()) {
case 201 -> System.out.println("201 booked\n" + response.body());
case 400 -> // HPB-00101 structural validation
System.out.println("400 validation\n" + response.body());
case 409 -> // HP-00803 the slot is no longer free
System.out.println("409 conflict\n" + response.body());
case 422 -> // HP-008xx booking precondition
System.out.println("422 precondition\n" + response.body());
default -> System.out.println(response.statusCode() + "\n" + response.body());
}
}
}
```
```go
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
// serviceType is the org-service coding from HealthcareService, verbatim, and is
// required; start/end come from the free slot. priority/description/
// patientInstruction and the insurance-snapshot extension are optional
// fields the decoder stores. The patient, practitioner (≥1 required) and room
// Location (required) participants are all decoded and persisted.
body := []byte(`{
"resourceType": "Appointment",
"status": "booked",
"priority": 5,
"description": "Consulta general — control",
"patientInstruction": "Llegar 10 minutos antes y traer estudios previos.",
"serviceType": [{"coding": [{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000f1",
"display": "Consulta general"
}]}],
"specialty": [{"coding": [{
"system": "https://fhir.huli.ai/r4/CodeSystem/specialty",
"code": "01965e2a-8c4d-7000-9011-0000000000d1",
"display": "Medicina general"
}]}],
"start": "2026-06-17T09:00:00.000-06:00",
"end": "2026-06-17T09:30:00.000-06:00",
"participant": [
{"actor": {"reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2"}, "status": "accepted"},
{"actor": {"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"}, "status": "accepted"},
{"actor": {"reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1"}, "status": "accepted"}
],
"extension": [{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-insurance-snapshot",
"extension": [
{"url": "provider", "valueString": "Seguros Monterrey"},
{"url": "policyNumber", "valueString": "POL-99812"},
{"url": "certificateNumber", "valueString": "CERT-44120"}
]
}]
}`)
req, err := http.NewRequest(http.MethodPost,
"https://api.huli.ai/fhir/R4/Appointment", 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)
}
switch resp.StatusCode {
case http.StatusCreated:
fmt.Printf("201 booked\n%s\n", out)
case http.StatusBadRequest: // HPB-00101 structural validation
fmt.Printf("400 validation\n%s\n", out)
case http.StatusConflict: // HP-00803 the slot is no longer free
fmt.Printf("409 conflict\n%s\n", out)
case http.StatusUnprocessableEntity: // HP-008xx booking precondition
fmt.Printf("422 precondition\n%s\n", out)
default:
fmt.Printf("%d\n%s\n", resp.StatusCode, out)
}
}
```
:::
The Run button above sends the **minimal** write — no patient participant, no `specialty` (only
required when the discovered service offers two or more specialties; see the full field
reference below). Chained from the steps above: `serviceTypeCode` (step 2), `practitionerRoleId`
+ `roomLocationRef` (step 3), `slotStart`/`slotEnd` (step 5).
A `201 Created` returns the stored `Appointment` with a server-assigned `id`. Set `status` to
`proposed` instead of `booked` if your flow needs an intermediate "requested, awaiting
confirmation" state before it firms up.
#### Full field reference
Every field the Appointment write surface touches. The public write routes through the same
scheduling service the in-app calendar uses, so the request
body must name the resources an appointment needs; the service then derives the appointment
type, specialty, and booking policy from the chosen service. Required: `status`, `start`, `end`,
`serviceType`, at least one practitioner participant, and a room `Location` participant — plus
`specialty` when the chosen service offers two or more specialties.
| Field | Req? | Honored on write | Notes |
| -------------------------------------------- | ----------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status` | **required** | yes | A valid FHIR appointment status; maps to the Huli status id. |
| `start` | **required** | yes | Slot start (from step 5). |
| `end` | **required** | yes | Slot end. |
| `serviceType[].coding` | **required** | yes | The `org-service` coding from `HealthcareService.type` (system `…/CodeSystem/org-service`, `code` = the org service UUID). Resolves to the org service; the service derives appointment type, specialty, booking policy, and per-service resource requirements. Missing or unresolvable → `422`. |
| `participant[].actor` `Practitioner/` | **required** (≥1) | yes | Decoded and persisted as the appointment's participants. At least one practitioner is required. |
| `participant[].actor` `Location/` | **required** | yes | The room. Decoded and persisted; missing → `400` (`HP-00816`). |
| `participant[].actor` `Patient/` | optional | yes | The patient participant (omit for walk-ins / admin meetings). |
| `participant[].actor` `Device/` | optional | yes | Equipment participant; decoded and persisted when the service requires it. |
| `priority` | optional | yes | Unsigned integer; stored verbatim (`0` = routine). |
| `description` | optional | yes | Reason / short label, stored on the appointment. |
| `patientInstruction` | optional | yes | Instructions shown to the patient. |
| `extension[]` `…/huli-insurance-snapshot` | optional | yes | Insurance snapshot: nested `provider` (required within the block), `policyNumber`, `certificateNumber`. |
| `cancelationReason` | optional | on cancel only | Resolved against the org's cancellation reasons on `PUT status=cancelled`; not read on create. |
| `appointmentType` | optional | server-derived | Read-only on write — derived from the chosen `serviceType` and re-emitted on read. |
| `specialty[].coding` | conditional | yes | The bookable `specialty` coding (system `…/CodeSystem/specialty`, `code` = the specialty UUID) copied verbatim from `HealthcareService.specialty`. **Required** when the chosen service offers ≥2 specialties — omitted → `422` (`HPB-00115`); a specialty the service does not offer → `422` (`HPB-00116`); a non-UUID code → `422` (`value`). For a single-/all-specialty service it is optional (derived server-side; a human/SNOMED-only coding is ignored). |
| `extension[]` `…/confirmation-status` | optional | server-derived | Read-only on write — tracks the patient-confirmation workflow; re-emitted on read. |
| `participant[].status` / `required` / `type` | optional | server-stamped | The read endpoint stamps these from the stored participant rows; input is not used. |
## What to verify
- HTTP status is `201`.
- The response body's `resourceType` is `Appointment` and it carries a server-assigned `id`.
- The `serviceType.coding[0].code` you sent round-trips unchanged — proof the org-service code
was accepted, not silently dropped.
- `start`/`end` match the slot you chose, and the practitioner + room participants are present.
- Re-search `GET /fhir/R4/Slot?actor=…` for the same window: the slot you booked is no longer
in the free list.
## What can go wrong
All errors return a FHIR `OperationOutcome`, never a bare string — `{severity, code,
diagnostics}`, with no `details` object. Branch on the HTTP status and `issue[0].code` (the
FHIR IssueType); the Huli code is the prefix of `issue[0].diagnostics`, split on `": "` to
extract it. Structural problems (a missing required field, malformed JSON) surface as
`HPB-00101`; the booking preconditions surface the practice-layer `HP-008xx` codes inside the
same `diagnostics`.
**`serviceType` missing or unresolvable.** The booking must carry a
`serviceType` whose `coding` uses the `org-service` system with a `code` that is a current org
service UUID. Absent → a `required` issue; present but the wrong system or an unparseable code →
a `value` issue. A bare name string is rejected. This is why step 2 lifts the coding verbatim
from `HealthcareService.type` rather than constructing one. (The old behavior — silently
defaulting to the org's first active service — has been removed.)
`HP-00807` — **no resource participant.** The booking needs at least
one `participant.actor` referencing a `Practitioner`. Resolve the practitioner in step 3 before
you build the participant array.
`HP-00816` — **room/location missing.** The booking needs a room:
a `participant.actor` referencing a `Location`. Take it from `PractitionerRole.location` in
step 4.
`HP-00817` — **the practitioner has no assigned locations.** Every
practitioner must have at least one assigned room to be bookable. If `PractitionerRole.location`
is empty, the practitioner cannot be booked until a room is assigned in HuliPractice.
`HP-00818` — **the room is not in the practitioners' assigned
locations.** The room you sent must be inside the participating practitioner's assigned set.
Sourcing the room from that practitioner's own `PractitionerRole.location` (step 4) avoids
this — a room taken only from `HealthcareService.location` can fall outside it.
`HPB-00115` — **a specialty must be selected.** The chosen service
offers two or more specialties (its `HealthcareService.specialty` has ≥2 entries), so the booking
must name which one in `Appointment.specialty` — the API will not guess. Copy one of the service's
`specialty[].coding` entries (the `specialty`-CodeSystem coding) verbatim into the Appointment.
`HPB-00116` — **specialty not offered by this service.** The
`specialty` you sent is not among the ones the service advertises. Pick a coding straight from the
service's `HealthcareService.specialty` list rather than constructing one. (A present-but-malformed
specialty `code` — a non-UUID — is rejected `422` with a `value` issue instead.)
`HP-00819` — **practitioner specialty mismatch.** When the service
carries a `specialty` (step 2), every participating practitioner must carry the booked specialty
in their `PractitionerRole.specialty`. Cross-check the role's specialty against the service's
before you book.
`HP-00803` — **the slot is no longer free.** Between your slot
search and your `POST`, someone else booked it (or it overlaps an existing appointment).
Re-run the step 5 slot search and pick another free slot; do not blindly retry the same body.
To cancel a booking later, `PUT` the `Appointment` with `status: "cancelled"` and a
`cancelationReason` whose code comes from the cancellation-reason ValueSet. Expand it with
`GET /fhir/R4/ValueSet/$expand?url=https://fhir.huli.ai/r4/ValueSet/cancellation-reason` and
pick a code from the returned `expansion.contains[]` — a free-text reason without a valid code
is rejected. A cancel is **blocked `409` (`HP-00812`)** when a clinical encounter is already
linked to the appointment. To mark a booking as a data-entry mistake instead, `PUT`
`status: "entered-in-error"` — that path takes no reason and runs no cancel guards.
A representative `422` precondition body:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "processing",
"diagnostics": "HP-00818: Room's location is not among the practitioners' assigned locations"
}
]
}
```
## Next recipes
- **[Scheduling an administrative meeting](/v1/recipes/scheduling-an-administrative-meeting)** —
book an internal meeting with no patient, a title, and external email invitees, against a
service whose appointment type is `administrative`.
- **[Wiring a read-only partner](/v1/recipes/wiring-a-read-only-partner)** — consume the
Appointment + Encounter feed read-only once bookings exist, resolving the
Practitioner/Organization references they point at.
- **[Run your first authenticated Patient search](/v1/recipes/getting-started-patient-search)**
— resolve the patient you book for by name or identifier first.
- **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
booking flow server-to-server.
========================================================================
# Choosing a CLI authentication path
# URL: https://developers.huli.ai/v1/recipes/cli-authentication-paths
# Decide between interactive OAuth (no CLI subcommand today), M2M client_credentials, and a one-off bearer token for the huli CLI — a decision table and the exact command for each.
# Choosing a CLI authentication path
The `huli` CLI talks to the same FHIR R4 surface three different ways, and the right
one depends on who runs it and how long it runs. Pick the path first, then copy the one
command that matches. This recipe maps each workload to a path, gives you the exact
invocation, and names the failures that tell you the path was wrong.
## Audience
You wire integrations against the Huli FHIR API and you are about to script the
`huli` CLI into something — a developer's laptop, a cron job, a CI pipeline, or a quick
one-shot from a shell. You know what a bearer token is and you have read the FHIR base
URL at least once.
## You'll need
- The `huli` CLI on your `PATH`, with `huli auth setup` available (run `huli --help` to
confirm the binary resolves).
- For the M2M path: an `api_key` registered with a JWKS URI, an RS384 signing key whose
public half is published at that JWKS URI, and the scopes the workload needs. An admin
on your organization registers the key in **Practice Settings → Integrations → API
Keys**.
- For the one-off path: an admin bearer token, minted once in the same place and shown
once. It is long-lived and scoped to one organization.
- The base host `https://api.huli.ai` and the FHIR base `https://api.huli.ai/fhir/R4/`.
The token endpoint is host-rooted at `https://api.huli.ai/auth/token`, not under
`/fhir`.
SMART discovery and JWKS are issuer-rooted under /fhir , not the
host root: discovery at
https://api.huli.ai/fhir/.well-known/smart-configuration and the
server's signing keys at
https://api.huli.ai/fhir/.well-known/jwks.json . The token POST,
by contrast, is host-rooted at https://api.huli.ai/auth/token .
Mixing these up is the most common first-run misconfiguration.
## End state
You have chosen one of three paths and run one authenticated request through it. The CLI
holds credentials in the shape that path expects — a stored M2M profile, an interactive
session, or a bearer string passed per command — and a Patient search returns a
`searchset` Bundle instead of an `OperationOutcome`.
## Steps
### 1. Match your workload to a path
Read the row that describes who runs the CLI and how often, then jump to that path's
command below.
| Workload | Path | CLI surface | Credential lifetime | Status |
| ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ---------------------------------------- | -------------------------------------------------------------------- | ----------------------- |
| A human running the CLI interactively from a laptop, acting as themselves | Interactive OAuth (Auth-Code + PKCE) | `huli auth login` | Short-lived session, refreshed in the background | No CLI subcommand today |
| An unattended server, cron job, or CI pipeline acting as a service, not a person | SMART Backend Services (`client_credentials` + `private_key_jwt`, RS384) | `huli auth setup` | 5-minute access token, re-minted automatically from your signing key | Available |
| A one-shot command, a debugging session, or a script that already holds an admin key | Admin bearer token passed per command | `--token` flag or `Authorization` header | Long-lived admin key, no refresh | Available |
The dividing questions, in order:
1. Is a person sitting at the keyboard, and do you want requests attributed to that
person? That is interactive OAuth — a v1 API auth mode the CLI has no subcommand for
today, so fall through to one of the next two.
2. Is the caller a service running without a human present? That is `huli auth setup`.
3. Is this a single throwaway call, or do you already hold an admin bearer token? Pass
it per command with `--token`.
### 2a. Interactive OAuth — no CLI subcommand today
huli auth login is not part of the current CLI. Interactive OAuth
(Auth-Code + PKCE) is a defined v1 auth mode on the API, but the CLI has no subcommand
that drives it. A human who needs to run the CLI uses an admin bearer token (step 2c)
scoped to what they need.
Treat the interactive row in the decision table as a signpost, not a CLI instruction: for
CLI access today, use M2M (`huli auth setup`, step 2b) or an admin bearer token (step 2c).
### 2b. M2M — `huli auth setup` (client_credentials + private_key_jwt)
This is the path for any unattended caller. The CLI signs a `private_key_jwt` client
assertion with your RS384 key, exchanges it at the token endpoint for a 5-minute access
token, and re-mints that token automatically as it expires.
Run the one-time setup:
```bash
huli auth setup \
--base-url https://api.huli.ai \
--client-id clinica-san-rafael-integration \
--jwks-uri https://integrations.clinica-san-rafael.example/jwks.json \
--private-key ./san-rafael-signing-key.pem \
--scope "system/Patient.rs system/Appointment.rs"
```
The flags map one-to-one onto the SMART Backend Services handshake:
- `--base-url` is the host root. The CLI derives the token endpoint as
`https://api.huli.ai/auth/token` and discovery as
`https://api.huli.ai/fhir/.well-known/smart-configuration` from it.
- `--client-id` is the `sub` of your signed assertion and identifies the `api_key` row.
- `--jwks-uri` must match the JWKS URI registered on that `api_key` — the server fetches
your public key from there to verify the RS384 signature.
- `--private-key` points at the RS384 private key whose public half lives at that JWKS
URI.
- `--scope` is the space-separated set of scopes to request. Request only what the
workload uses.
Once setup completes, every CLI command authenticates from the stored profile — no token
flag, no header. Run any read command (a Patient search filtered by name, for example)
and behind it the CLI POSTs the assertion to
`https://api.huli.ai/auth/token`, receives an RS384 access token valid for 5 minutes,
and attaches it as a bearer on the FHIR request. When the token expires, the next command
re-mints it from your key — you never handle the access token yourself.
Pick scopes from the v1 set. Letters are `r`=read, `s`=search, `c`=create, `u`=update.
Read plus search is `.rs`; full write is `.cru`.
- Read + write resources: ,
, ,
.
- Read-only resources: ,
.
- Provenance is create plus read plus search only, client-POSTed:
together with
.
### 2c. One-off — admin bearer token via `--token` or header
For a single call, a debugging session, or a script that already holds an admin key,
skip the stored profile and pass the token per command. The admin bearer token comes
from **Practice Settings → Integrations → API Keys**, is shown once, and is long-lived.
Export it so it never lands in shell history:
```bash
export HULI_API_KEY=""
```
Pass it to the CLI with the `--token` flag on any read command, or — calling the API
directly rather than through the CLI — ride the same token in the `Authorization` header
with one space after `Bearer`:
```bash
curl "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
The admin bearer token does not expire on a timer the way the M2M access token does, so
there is no refresh to manage — but that also means a leaked admin token stays valid
until an admin revokes it. Keep it to one-off and interactive-human use; for anything
unattended and long-running, prefer the M2M path, where each access token lives 5 minutes.
## What to verify
- For the M2M path: `huli auth setup` exits cleanly, and a follow-up read command (a
Patient search by name) with no `--token` flag returns a `Bundle` of type `searchset`.
That proves the stored profile minted a token without you handling it.
- For the one-off path: the same read with `--token "$HULI_API_KEY"` returns a
`searchset` Bundle. Status is `200`.
- Either way, the response `resourceType` is `Bundle` and `type` is `searchset` — not
`OperationOutcome`.
- On the M2M path, you requested only the scopes the workload uses. A read-only reporting
job should not request `.cru` on any resource.
## What can go wrong
Every failure comes back as a FHIR `OperationOutcome` with exactly this shape — no
`details`, no `coding`, no `text`:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "security",
"diagnostics": "HPB-00106: Authentication failed"
}
]
}
```
Classify machine-side on the HTTP status and `issue[0].code` (the FHIR IssueType:
`400`→`invalid`, `401`→`security`, `403`→`forbidden`, `404`→`not-found`, `409`→`conflict`,
`429`→`throttled`, `5xx`→`exception`). The Huli code is the prefix of
`issue[0].diagnostics` — split on `": "` to read it. The five you will hit choosing a
path:
`HPB-00106` — auth failed. The credential is missing,
malformed, or revoked. On the M2M path, the assertion signature did not verify — confirm
`--jwks-uri` matches the URI registered on the `api_key` and that `--private-key` is the
RS384 key whose public half sits there. On the one-off path, confirm the header reads
`Authorization: Bearer ` with a single space and that `$HULI_API_KEY` is exported
in this shell.
`HPB-00107` — auth expired. You presented an access token past
its lifetime. This is specific to the 5-minute M2M token: it means a cached token outlived
its window. With `huli auth setup` the CLI re-mints automatically, so seeing this usually
means you pinned a raw token by hand instead of letting the stored profile refresh it.
You will not see this from an admin bearer token, which does not expire on a timer.
`HPB-00104` — insufficient scope. The credential authenticated
but lacks the scope the request needs. On the M2M path, widen `--scope` and re-run
`huli auth setup` (and confirm the `api_key` is allowed those scopes). On the one-off
path, confirm the admin key was granted the scopes the request needs; if not, mint a new
key in **Practice Settings → Integrations → API Keys**.
`HPB-00101` — validation error. A request parameter is
malformed — most often an un-encoded accent in a hand-built URL. Encode `á` as `%C3%A1`,
or let the CLI and HTTP clients encode the raw string for you.
`HPB-00105` — rate limited. You exceeded the per-key request
budget. Read the `Retry-After` response header and back off for that many seconds before
retrying. Unattended M2M jobs should honor `Retry-After` rather than tight-looping.
## Next recipes
- **Run your first authenticated Patient search** — one round-trip against the FHIR
API with an admin bearer token, and the four errors you hit first.
- **Authenticate as a SMART Backend Service** — the full `client_credentials` +
`private_key_jwt` (RS384) handshake under the hood, for when you want to build the
token exchange yourself instead of letting the CLI drive it.
- **Paginate a large patient list** — follow the `Bundle.link` entry whose relation is
`next` to walk every page of a `searchset`, whichever auth path you chose here.
========================================================================
# Creating a clinical encounter
# URL: https://developers.huli.ai/v1/recipes/creating-an-encounter
# Record a visit as a FHIR R4 Encounter — discover the practitioner the create requires as a participant, then POST the Encounter with its status, ActCode class, patient subject, and period.
# Creating a clinical encounter
Record a visit for an existing patient as a stored `Encounter`. You will authenticate, discover
the practitioner the create requires as a participant, then `POST` the encounter with its
`status`, `class`, patient `subject`, and `period`. Three scopes carry the flow:
system/Encounter.cru for the write, system/Patient.rs so the
subject resolves, and system/Practitioner.rs to discover the participant.
The participant is the part most first writes miss. An `Encounter` create requires at least one
practitioner participant — the visit has to name who attended it. The discovery step below hands
you a real `Practitioner` reference so the participant array satisfies that check, and the patient
subject and class round out a body the server accepts.
## Audience
You integrate an EHR and record visits into HuliPractice. You have already
[registered or resolved the patient](/v1/recipes/registering-a-patient), you read a `Bundle`
without a viewer, and you know what a FHIR reference is. You want to take a visit from a patient
plus a practitioner to a `201 Created` `Encounter`.
## You'll need
- A bearer token from HuliPractice (**Practice Settings → Integrations → API Keys**), or a
SMART Backend Services access token. See [Bearer Tokens](/v1/auth/bearer) for provisioning
and [`POST /auth/token`](/v1/auth) for the token exchange — this recipe assumes you already
hold one.
- These three scopes on that token:
- — create `Encounter` (`.cru` also grants read +
search).
- — read + search `Patient`; the encounter's `subject`
must reference a patient that resolves in your organization.
- — read + search `Practitioner` and
`PractitionerRole` to discover the participant.
- The `id` of the patient the visit is for. Resolve it with
[a Patient search](/v1/recipes/getting-started-patient-search) if you only hold a name or
identifier.
- `curl`, or Node, Python, Java, or Go if you prefer a language client.
`Encounter.class` is a fixed FHIR value set — the v3 ActCode codes `AMB` (ambulatory), `EMER`
(emergency), `IMP` (inpatient), and `VR` (virtual), under
`http://terminology.hl7.org/CodeSystem/v3-ActCode`. It is not a per-organization catalog you
discover; pick the one ActCode that matches the visit. A class code outside that set is
rejected.
## End state
You hold a `201 Created` whose body is the stored `Encounter` — with a server-assigned `id`, the
patient as `subject`, the discovered practitioner in `participant[0].individual`, the ActCode
`class` you chose, and the `period` you sent. The encounter is then readable and searchable by
`patient` or `practitioner`.
## Steps
### 1. Export the token and the patient id
```bash
export HULI_TOKEN=""
export PATIENT_ID="01965e2a-8c4d-7000-9001-0000000000a2"
```
Resolve a real patient id in your sandbox instead of the illustrative one above:
### 2. Discover the practitioner
The encounter needs a practitioner participant. Search `Practitioner` by name to get the
reference; the `id` of the matching entry is what you put in
`participant[0].individual.reference`.
The Practitioner search pages on offset pagination (`_count` + `_offset`), the same model the
discovery resources use. If you also need the practitioner's rooms or specialties — for example
to pre-check a downstream booking — read their `PractitionerRole`; for recording a completed
visit, the `Practitioner` reference alone is enough.
:::CodeGroup
```bash
curl "https://api.huli.ai/fhir/R4/Practitioner?name=Fern%C3%A1ndez&_count=20" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```typescript
const params = new URLSearchParams({ name: 'Fernández', _count: '20' });
const resp = await fetch(`https://api.huli.ai/fhir/R4/Practitioner?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
Accept: 'application/fhir+json',
},
});
const bundle = await resp.json();
// The participant reference is Practitioner/.
const practitioner = bundle.entry?.[0]?.resource;
console.log(`Practitioner/${practitioner?.id}`);
```
```python
import os
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/Practitioner",
params={"name": "Fernández", "_count": 20},
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
bundle = resp.json()
# The participant reference is Practitioner/.
practitioner = bundle["entry"][0]["resource"]
print(f"Practitioner/{practitioner['id']}")
```
:::
### 3. POST the Encounter
Assemble the discovered practitioner and the patient into the create body: `status` (use
`finished` for a completed visit or `in-progress` while it is ongoing), the ActCode `class`, the
patient `subject`, a `participant` array naming the practitioner, and the `period`.
The body below is the **comprehensive** form — every field the create decoder honors on an
Encounter write, including the optional `appointment` link, the visit `reasonCode`, and the
`contained` clinical resources (an ICD-10 `Condition` for a diagnosis and a `ClinicalImpression`
for the subjective summary). Required fields are flagged inline; the **Full field reference**
after the example lists each field and whether the decoder reads it on write. A minimal write
needs only `status`, `subject`, and one `participant`.
Use `finished` for a completed visit — that is the FHIR R4 status. The internal Huli status
"completed" maps to the FHIR token `finished`, so always send `finished`, never `completed`.
For an ongoing visit send `in-progress` and omit `period.end`.
:::CodeGroup
```bash
curl -i -X POST https://api.huli.ai/fhir/R4/Encounter \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{
"resourceType": "Encounter",
"status": "finished",
"class": {
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "AMB",
"display": "ambulatory"
},
"subject": {
"reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2"
},
"participant": [
{
"individual": {
"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"
}
}
],
"appointment": [
{
"reference": "Appointment/01965e2a-8c4d-7000-9050-0000000000e1"
}
],
"period": {
"start": "2026-06-15T09:00:00.000-06:00",
"end": "2026-06-15T09:30:00.000-06:00"
},
"reasonCode": [
{ "text": "Control de hipertensión" }
],
"contained": [
{
"resourceType": "Condition",
"id": "condition-1",
"code": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/icd-10",
"code": "I10",
"display": "Hipertensión esencial (primaria)"
}
],
"text": "Hipertensión esencial (primaria)"
},
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" }
},
{
"resourceType": "ClinicalImpression",
"id": "clinical-impression-1",
"status": "completed",
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" },
"summary": "Paciente refiere cefalea ocasional; sin otros síntomas."
}
]
}'
```
```typescript
const encounter = {
resourceType: 'Encounter',
status: 'finished', // required — FHIR R4 status; Huli's "completed" maps to this
class: {
// optional — only class.code is read; AMB | EMER | IMP | VR (defaults to AMB if omitted)
system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
code: 'AMB',
display: 'ambulatory',
},
subject: { reference: `Patient/${process.env.PATIENT_ID}` }, // required
participant: [
// At least one practitioner participant is required; participant[0].individual is read.
{ individual: { reference: 'Practitioner/01965e2a-8c4d-7000-9001-0000000000c1' } },
],
appointment: [
// optional — links the visit to the appointment that scheduled it (appointment[0] read)
{ reference: 'Appointment/01965e2a-8c4d-7000-9050-0000000000e1' },
],
period: {
start: '2026-06-15T09:00:00.000-06:00',
end: '2026-06-15T09:30:00.000-06:00', // omit for an in-progress visit
},
reasonCode: [{ text: 'Control de hipertensión' }], // optional — reasonCode[0].text read
contained: [
{
// ICD-10 diagnosis — code.coding[0] + code.text read into the encounter's diagnoses
resourceType: 'Condition',
id: 'condition-1',
code: {
coding: [
{
system: 'http://hl7.org/fhir/sid/icd-10',
code: 'I10',
display: 'Hipertensión esencial (primaria)',
},
],
text: 'Hipertensión esencial (primaria)',
},
subject: { reference: `Patient/${process.env.PATIENT_ID}` },
},
{
// subjective summary — ClinicalImpression.summary read
resourceType: 'ClinicalImpression',
id: 'clinical-impression-1',
status: 'completed',
subject: { reference: `Patient/${process.env.PATIENT_ID}` },
summary: 'Paciente refiere cefalea ocasional; sin otros síntomas.',
},
],
};
const resp = await fetch('https://api.huli.ai/fhir/R4/Encounter', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
'Content-Type': 'application/fhir+json',
Accept: 'application/fhir+json',
},
body: JSON.stringify(encounter),
});
if (resp.status === 201) {
const created = (await resp.json()) as { id: string };
console.log('recorded', created.id);
} else {
const outcome = (await resp.json()) as { issue: { code: string; diagnostics: string }[] };
// issue[0].code is the FHIR IssueType; diagnostics describes the problem.
console.log(resp.status, outcome.issue[0].code, outcome.issue[0].diagnostics);
}
```
```python
import os
import requests
patient_ref = f"Patient/{os.environ['PATIENT_ID']}"
encounter = {
"resourceType": "Encounter",
"status": "finished", # required — FHIR R4 status; Huli's "completed" maps to this
"class": {
# optional — only class.code is read; AMB | EMER | IMP | VR (defaults to AMB if omitted)
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "AMB",
"display": "ambulatory",
},
"subject": {"reference": patient_ref}, # required
"participant": [
# At least one practitioner participant is required; participant[0].individual is read.
{"individual": {"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"}},
],
"appointment": [
# optional — links the visit to the appointment that scheduled it (appointment[0] read)
{"reference": "Appointment/01965e2a-8c4d-7000-9050-0000000000e1"},
],
"period": {
"start": "2026-06-15T09:00:00.000-06:00",
"end": "2026-06-15T09:30:00.000-06:00", # omit for an in-progress visit
},
"reasonCode": [{"text": "Control de hipertensión"}], # optional — reasonCode[0].text read
"contained": [
{
# ICD-10 diagnosis — code.coding[0] + code.text read into the encounter's diagnoses
"resourceType": "Condition",
"id": "condition-1",
"code": {
"coding": [
{
"system": "http://hl7.org/fhir/sid/icd-10",
"code": "I10",
"display": "Hipertensión esencial (primaria)",
}
],
"text": "Hipertensión esencial (primaria)",
},
"subject": {"reference": patient_ref},
},
{
# subjective summary — ClinicalImpression.summary read
"resourceType": "ClinicalImpression",
"id": "clinical-impression-1",
"status": "completed",
"subject": {"reference": patient_ref},
"summary": "Paciente refiere cefalea ocasional; sin otros síntomas.",
},
],
}
resp = requests.post(
"https://api.huli.ai/fhir/R4/Encounter",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
},
json=encounter,
timeout=30,
)
if resp.status_code == 201:
print("recorded", resp.json()["id"])
else:
outcome = resp.json()
# issue[0].code is the FHIR IssueType; diagnostics describes the problem.
print(resp.status_code, outcome["issue"][0]["code"], outcome["issue"][0]["diagnostics"])
```
```go
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
// status "finished" is the FHIR token for a completed visit (Huli's
// "completed" maps to it). class is the fixed ActCode set; at least one
// practitioner participant is required. appointment, reasonCode, and the
// contained Condition/ClinicalImpression are optional enrichment the create
// decoder honors.
patientRef := "Patient/" + os.Getenv("PATIENT_ID")
body := []byte(`{
"resourceType": "Encounter",
"status": "finished",
"class": {
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "AMB",
"display": "ambulatory"
},
"subject": {"reference": "` + patientRef + `"},
"participant": [
{"individual": {"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"}}
],
"appointment": [
{"reference": "Appointment/01965e2a-8c4d-7000-9050-0000000000e1"}
],
"period": {
"start": "2026-06-15T09:00:00.000-06:00",
"end": "2026-06-15T09:30:00.000-06:00"
},
"reasonCode": [{"text": "Control de hipertensión"}],
"contained": [
{
"resourceType": "Condition",
"id": "condition-1",
"code": {
"coding": [{
"system": "http://hl7.org/fhir/sid/icd-10",
"code": "I10",
"display": "Hipertensión esencial (primaria)"
}],
"text": "Hipertensión esencial (primaria)"
},
"subject": {"reference": "` + patientRef + `"}
},
{
"resourceType": "ClinicalImpression",
"id": "clinical-impression-1",
"status": "completed",
"subject": {"reference": "` + patientRef + `"},
"summary": "Paciente refiere cefalea ocasional; sin otros síntomas."
}
]
}`)
req, err := http.NewRequest(http.MethodPost,
"https://api.huli.ai/fhir/R4/Encounter", 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)
}
switch resp.StatusCode {
case http.StatusCreated:
fmt.Printf("201 recorded\n%s\n", out)
case http.StatusBadRequest: // structural validation (missing participant, bad class/status)
fmt.Printf("400 validation\n%s\n", out)
case http.StatusUnprocessableEntity: // a subject/practitioner reference that does not resolve
fmt.Printf("422 reference not found\n%s\n", out)
case http.StatusConflict: // patient merged (HPB-00108) or deceased (HPB-00109)
fmt.Printf("409 patient not writable\n%s\n", out)
default:
fmt.Printf("%d\n%s\n", resp.StatusCode, out)
}
}
```
:::
The Run button above sends the **minimal** encounter — `status`, `class`, `subject`, and one
practitioner participant — chaining `patientId` (step 1) and `practitionerId` (step 2). The
`appointment` link, `reasonCode`, and `contained` clinical resources from the full body are all
optional.
A `201 Created` returns the stored `Encounter` with a server-assigned `id`. The `class` mapping
round-trips (the `AMB` ActCode you sent comes back as `AMB`), and the participant carries the
practitioner you discovered.
#### Full field reference
Every field the Encounter create decoder reads on write. "Honored" means the create decoder maps
the field into the stored visit; fields not listed (or marked **ignored**) are accepted but not
persisted from your input. Required: `status`, `subject`, and at least one `participant`.
| Field | Req? | Honored on write | Notes |
| ------------------------------------- | ------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `status` | **required** | yes | FHIR status; send `finished` (maps to Huli `completed`) or `in-progress`. |
| `subject.reference` | **required** | yes | `Patient/` — must resolve in the organization (else `422`). |
| `participant[0].individual.reference` | **required** | yes | `Practitioner/` — at least one participant is required; the first individual is read. |
| `class.code` | optional | yes | One of `AMB`/`EMER`/`IMP`/`VR`; maps to ambulatory/emergency/inpatient/virtual. Absent defaults to `AMB`. |
| `class.system` / `display` | optional | ignored | Re-emitted from the code mapping on read. |
| `appointment[0].reference` | optional | yes | `Appointment/` linking the visit to its scheduling appointment. |
| `period.start` | optional | yes | Visit start. |
| `period.end` | optional | yes | Visit end; omit for an `in-progress` visit. |
| `reasonCode[0].text` | optional | yes | Free-text reason for the visit. Only the first entry's `text` is read. |
| `contained[]` `Condition` | optional | yes | ICD-10 diagnosis: `code.coding[0]` (system/code/display) + `code.text` are read into the encounter's diagnoses. |
| `contained[]` `ClinicalImpression` | optional | yes | `summary` is read as the subjective note. |
| `participant[].type` / `period` | optional | ignored | Only `individual` is consumed on write. |
| `diagnosis[]` | optional | ignored on write | Built on read from the `contained` Conditions — send diagnoses as `contained` Conditions, not as `diagnosis[]` references. |
| `serviceProvider` | optional | ignored | The server stamps the token's organization. |
Clinical content — vital signs, lab results — is not carried inside the `Encounter` body. Each
measurement is a separate `Observation` resource that references this encounter through its
`encounter` field. Record those after the encounter exists; see
[Send lab results to the chart](/v1/recipes/posting-lab-observations-lis), which links its
`Observation` to both the patient and the encounter. The full Encounter ↔ Observation model is
in the [FHIR Implementation Guide](https://developers.huli.ai/fhir/).
The same visit is also a **`Composition`** — a sibling projection of this exact row. `Encounter`
exposes the visit envelope (status, class, period, participant); `Composition` exposes the
clinical narrative (chief complaint, history, findings, assessment, plan) as LOINC-coded
sections. Read or amend that narrative — with optimistic concurrency — through the BAA-gated
`medical_records` scope; see
[Writing and amending a clinical note](/v1/recipes/writing-a-clinical-note). The two projections
own different fields, so a `Composition` write never clobbers the diagnoses this `Encounter`
surface set.
## What to verify
- HTTP status is `201`.
- The response body's `resourceType` is `Encounter` and it carries a server-assigned `id`.
- `status` is `finished` (or `in-progress` if ongoing) and `class.code` is the ActCode you sent.
- `subject.reference` resolves to your `PATIENT_ID`, and `participant[0].individual.reference`
is the practitioner you discovered in step 2.
- `period.start` matches what you sent; `period.end` is present for a finished visit and absent
for an in-progress one.
## What can go wrong
All errors return a FHIR `OperationOutcome`, never a bare string — `{severity, code,
diagnostics}`, with no `details` object. Branch on the HTTP status and `issue[0].code` (the
FHIR IssueType) for machine classification; the `diagnostics` string describes the specific
problem. Structural problems on a writable resource are the `HPB-00101` validation family.
**No practitioner participant.** The create requires at least one
participant with a Practitioner `individual` reference — an `Encounter` records who attended the
visit. A body with an empty or absent `participant` array is rejected with `issue[0].code`
`required` and a diagnostics of "at least one participant (practitioner) is required". Discover
the practitioner in step 2 and build the participant before posting.
**Missing subject, bad status, or unknown class.** `subject` (a
patient reference) and `status` are required; `class.code` must be one of `AMB`, `EMER`, `IMP`,
`VR`. On **create**, `status` is restricted to the four round-trippable states — `planned`,
`in-progress`, `finished`, `cancelled`; the transitional/terminal markers (`arrived`, `triaged`,
`onleave`, `entered-in-error`, `unknown`) are rejected with `Encounter.status must be one of:
planned, in-progress, finished, cancelled on create`. Huli's internal `completed` is not a FHIR
status (send `finished`), and a class code outside the ActCode set also fails. (A `PUT` accepts
the broader FHIR status set, governed by the status-transition table.) Send `finished`/`in-progress`
and a valid ActCode.
**Subject or practitioner does not resolve.** A `subject` or
`participant.individual` reference whose UUID is well-formed but does not name a patient /
practitioner in your organization is rejected with a diagnostics of "Referenced Patient not
found in organization" (or "Referenced Practitioner not found in organization"). Resolve both
against Patient / Practitioner search first, and confirm the token's organization owns them.
**The patient cannot accept new clinical data.** A subject that has
been merged into another record (`HPB-00108`) or marked deceased (`HPB-00109`) is rejected — the
encounter would attach clinical data to a patient that can no longer take it. Resolve the
surviving record (for a merge) or stop, and do not retry the same subject.
A representative `400` body for the missing-participant case:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "required",
"diagnostics": "at least one participant (practitioner) is required",
"expression": ["Encounter.participant"]
}
]
}
```
## Next recipes
- **[Send lab results to the chart](/v1/recipes/posting-lab-observations-lis)** — record
the visit's vitals and lab results as `Observation` resources linked to this encounter.
- **[Registering a patient](/v1/recipes/registering-a-patient)** — onboard the patient first
when the subject does not yet exist, including the MX NOM-024 address path.
- **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
encounter flow server-to-server.
========================================================================
# Creating and sharing an API key as a clinic admin
# URL: https://developers.huli.ai/v1/recipes/creating-and-sharing-an-api-key
# Mint an admin bearer API key in Practice Settings, choose its scopes, capture the one-time secret, then share it with an integration partner and rotate it on a schedule.
# Creating and sharing an API key as a clinic admin
Mint an admin bearer API key from Practice Settings, pick the scopes your integration
partner actually needs, capture the secret on the one screen that ever shows it, hand it
off without leaking it, and set the rotation habit that keeps the whole thing safe. This
is the workflow you run before a vendor writes a single line of code.
## Audience
You run operations or administration at a clinic — at Clínica San Rafael, that's the
admin who manages access for Doctora María Fernández's integration vendor. You are
comfortable in Practice Settings and on a terminal for one verification command, but you
are not the one building the integration. You decide which data leaves the clinic and who
holds the key.
## You'll need
- An admin-role user on your organization in HuliPractice. Only admins see the API Keys
surface; a clinician or front-desk login does not.
- A short list from your integration partner of exactly which resources they read and
write. You translate that list into scopes in step 2 — granting more than they need is
the most common avoidable risk.
- A secure channel to hand the secret to the partner — a password manager share, an
enterprise secrets vault, or an equivalent. Plain email and chat do not count.
- `curl` (or any HTTP client) for the single verification request at the end. You can
also hand the verification step to the partner.
An admin bearer token is a long-lived credential scoped to one organization — Clínica San
Rafael's data, and nothing from any other clinic. It does not expire on a timer the way a
SMART Backend Services access token does. Treat it like a master key to that organization:
it stays in a secrets manager, never in email, chat, a shared doc, or a code repository.
## End state
A named, active API key exists on your organization with the scopes your partner needs.
You have captured its secret once, shared it through a secure channel, and confirmed it
works with one `200 OK` from a Patient search. You know how to rotate it and how to revoke
it the moment it is no longer needed.
## Steps
### 1. Open the API Keys surface
In HuliPractice, go to **Practice Settings**, open the **Integrations** section, and
select **API Keys**. This page lists every key on your organization with its name, its
scopes, its status, and when it was last used. If the section is absent, you are not
signed in as an admin — switch to an admin login before continuing.
Select **Create API key** to start a new one.
### 2. Name the key and choose its scopes
Give the key a name that identifies the partner and the purpose, not the person who
created it. A name like `San Rafael — Lab results sync` survives staff turnover; `Maria's
key` does not. You will read this name later when you decide what to rotate or revoke, so
make it self-explanatory.
Then select the scopes. Each scope is `system/.`, where the permission
letters are `r` (read), `s` (search), `c` (create), and `u` (update). Two combinations
cover almost every integration:
- Read plus search — the `.rs` form, e.g. . Grant this
when the partner only pulls data out.
- Full write — the `.cru` form, e.g. . Grant this when
the partner also creates and updates records. It includes read.
Which resources you can grant depends on whether they are writable in v1:
| Resource | Available scopes |
| ----------------- | -------------------------------------------------------------------------------------------------------- |
| Patient | · |
| Appointment | · |
| Encounter | · |
| Observation | · |
| Composition | · (BAA-gated) |
| DocumentReference | · (BAA-gated) |
| Practitioner | (read-only) |
| Organization | (read-only) |
| Provenance | · |
Practitioner and Organization are read-only in v1, so only the `.rs` form exists.
Provenance is the audit-trail resource a partner POSTs alongside a write; it offers
read, search, and create (`.rs` and `.c`), and no update. (Encounters, observations,
medication/service requests, compositions, and document references all sit under one
BAA-gated "Clinical information" card; the webhook Subscription scopes are their own
"Outbound webhooks" card — see the [Scopes reference](/v1/scopes).)
Grant the narrowest set that does the job. A lab-results integration that only reads
patients and writes observations needs and
— not write access to appointments or encounters.
Every extra scope widens what a leaked key exposes. You can mint a second, separate key
for a second partner rather than over-scoping one shared key.
**Clinical scopes require a signed BAA.** The sensitive **Clinical information** card
(Encounter, Observation, MedicationRequest, ServiceRequest, Composition, DocumentReference)
exposes protected health information, so the surface makes
you attest to a Business Associate Agreement (or equivalent) before it will mint a key that
carries any of those scopes. Demographic and scheduling scopes (Patient, Appointment,
Practitioner, Organization) are not gated. Only enable a clinical card for a partner you have a
BAA with.
### 3. Capture the secret — it is shown once
On confirmation, the surface displays the full secret token one time. This is the only
moment the secret is ever visible — Practice Settings stores a hash, not the token, so it
cannot show the value again on any later visit.
Copy the secret immediately and place it in your secrets manager before you leave or close
the screen. If you navigate away without copying it, the key still exists but its secret is
unrecoverable; your only path is to delete that key and mint a new one (steps 1–3).
After you have stored the secret, close the reveal. The list now shows the key as active,
with its name and scopes, but never the secret again.
### 4. Share it with your integration partner
Hand the secret to the partner through the secure channel you prepared — a password
manager share or a secrets vault, scoped to just the people who operate the integration.
Send the partner three things:
- The secret token itself (through the secure channel, never inline in a message).
- The base URL, https://api.huli.ai , with the FHIR R4 path under
/fhir/R4/ .
- The list of scopes you granted, so the partner builds against exactly what the key
allows and is not surprised by a on a resource you withheld.
The partner authenticates by sending the token as an HTTP `Authorization: Bearer` header
on every request. They do not call the token endpoint and they do not need your private
keys — the admin bearer token is the credential as-is.
Do not paste the secret into email, chat, a ticket, a shared spreadsheet, or a code
repository, and do not screenshot the reveal screen into any of those. A token in a chat
log is a token in everyone's search history. If it lands in one of those places even once,
treat it as compromised and rotate it (step 6).
### 5. Verify the key works
Run one authenticated request to confirm the key is live and correctly scoped. This
example searches patients by name, so it needs a key with
(or ). You can run it yourself or hand it to the
partner.
```bash
export HULI_API_KEY=""
curl "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
A working key returns with a FHIR `Bundle` of type
`searchset`. Encode the accent in the name (`á` becomes `%C3%A1`) so the search matches —
this query looks up Doctora María Fernández's patients whose name starts with `Fernández`.
### 6. Rotate and revoke
Rotation replaces a key's secret without an outage: mint a new key (steps 1–3), share it
with the partner (step 4), let them cut over and verify (step 5), then revoke the old one.
Two keys are valid at once during the overlap, so nothing breaks mid-cutover. Rotate on a
schedule — a calendar reminder per partner — and immediately if a secret is ever exposed.
To revoke, return to **Practice Settings → Integrations → API Keys**, find the key by the
name you gave it in step 2, and revoke it. A revoked key stops authenticating right away;
every request with it then returns with `HPB-00106` — see What
can go wrong. Revoke a key the moment a partnership ends or a key is no longer in use — an
unused active key is pure risk with no upside.
The name you chose in step 2 is what makes rotation and revocation safe. When you hold
three keys for three partners, `San Rafael — Lab results sync` tells you exactly which one
to revoke; `key 3` does not. This is why the naming convention is worth the few extra
seconds up front.
## What to verify
- The new key appears in **Practice Settings → Integrations → API Keys** as active, with
the name and scopes you intended.
- You captured the secret on the one-time reveal and stored it in a secrets manager — not
in email, chat, or a repository.
- The verification request in step 5 returns with a `Bundle` of
type `searchset`.
- The granted scopes match the partner's stated need and no more.
- You have a rotation reminder set and you know which list row to revoke when the
partnership ends.
## What can go wrong
The API returns errors as a FHIR `OperationOutcome`, not a bare string. The HTTP status and
`issue[0].code` (the FHIR IssueType) classify the failure; the Huli code (`HPB-…`) is the
prefix of `issue[0].diagnostics`, split on `": "`. There is no `details` object and no
`text`. These are the failures you will see while standing up a key:
`HPB-00106` — auth failed. The token is missing, malformed, or
revoked. Confirm the header reads `Authorization: Bearer ` with a single space, that
you pasted the full secret from the reveal screen, and that the key still shows as active in
the list. After a revoke (step 6) this is the expected response for the old key.
`HPB-00104` — insufficient scope. The token authenticated but
lacks the scope the request needs — for the step 5 search, .
The partner is calling a resource you did not grant. Re-mint the key with the right scopes,
or confirm the partner is calling only what you granted.
`HPB-00101` — validation error. A request parameter is malformed —
most often an un-encoded accent in the `name` search. Encode `á` as `%C3%A1`.
`HPB-00105` — rate limited. The key exceeded its request budget.
The response carries a `Retry-After` header; wait that many seconds before retrying. A
partner that trips this constantly is polling too aggressively — a workflow conversation,
not a key problem.
A representative `403` body:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
```
A second `401`, `HPB-00107` (auth expired), applies to the short-lived access tokens issued
by SMART Backend Services, not to an admin bearer token. If a partner sees it while using
the key you minted here, they are sending a SMART access token instead of your admin bearer
token — a sign they wired up the wrong auth mode.
## Next recipes
- **Run your first authenticated Patient search** — read the `searchset` Bundle your
verification request returns and recognize the four first-run failures in depth.
- **Authenticate as a SMART Backend Services client** — when a partner needs short-lived,
key-signed tokens (`client_credentials` + `private_key_jwt`, RS384) instead of a
long-lived admin bearer token.
- **Create and update a Patient** — move a partner from
to and POST/PUT patient records.
- **Write an Observation with a Provenance trail** — pair
with so
every write carries its audit record.
========================================================================
# Sync a daily patient list with the huli CLI
# URL: https://developers.huli.ai/v1/recipes/daily-roster-sync-cli
# Pull each day's patients and appointments with the huli CLI on a cron schedule — cursor-based pagination that resumes mid-walk on restart, plus 429 Retry-After backoff and id-level dedup at load.
# Sync a daily patient list with the huli CLI
Keep a daily mirror of your patients and appointments: schedule a nightly pull of every
`Patient` and `Appointment` for one organization, write each page to durable storage, and
resume from the exact page you left off when the host reboots mid-run. The loop is cursor-based, so a restart re-reads the last unfinished page
and continues — it commits each page before advancing the checkpoint, and an id-level
dedup at load time collapses any re-read page into a single row per resource.
## Audience
You run server-to-server integrations and own a cron host. You have synced a paginated
API before, you know what a checkpoint file buys you, and you want a patient-and-appointment
mirror that survives a reboot — backed by an id-level dedup at load time, not a hope that
the walk never repeats a page.
## You'll need
- The huli CLI on the cron host. `huli version` should print a `1.x` build; confirm it
speaks the v1 public API with `huli auth status` (below), which fails loudly against an
older contract.
- A SMART Backend Services client registered in HuliPractice (**Practice Settings →
Integrations → API Keys**), holding these scopes:
- — read plus search on Patient.
- — read plus search on Appointment.
- The client's RS384 private key on the cron host (file mode `0600`), plus the
`client_id` issued at registration.
- A writable checkpoint directory the cron user owns — this recipe uses
`/var/lib/huli-sync`.
SMART Backend Services issues a 5-minute access token from
POST https://api.huli.ai/auth/token (`client_credentials` +
`private_key_jwt`, RS384). The token endpoint is host-rooted — it sits at `/auth/token`,
not under `/fhir`. The CLI mints and refreshes that token for you once `huli auth setup`
has stored the client credentials; you never hand-build the assertion in the loop.
This recipe uses huli auth setup (machine-to-machine, shipping
today). Interactive browser login — huli auth login (Auth-Code +
PKCE) — is forthcoming and not in the current CLI. Do not script against it yet.
## End state
A cron job runs nightly at 02:15 America/Mexico_City. Each run walks every page of the
`Patient` searchset, then every page of the `Appointment` searchset for Doctora María
Fernández's organization at Clínica San Rafael, appending each page to a dated NDJSON
file under `/var/lib/huli-sync`. A checkpoint file records the cursor of the last page
committed. If the host reboots mid-run, the next start reads the checkpoint and resumes
from that cursor — at worst re-reading the one page that was in flight. The step-5 load
dedups on resource `id`, so the final mirror carries one row per `Patient` and
`Appointment`.
## Steps
### 1. Store the machine-to-machine credentials once
Register the client once. `huli auth setup` reads the private key, records the `client_id`
and the token endpoint, and persists an encrypted profile under `~/.config/huli/`. Run it
once as the cron user, not inside the cron job.
huli auth setup --profile roster-sync --client-id $HULI_CLIENT_ID --private-key /etc/huli/roster-sync.pem --token-url https://api.huli.ai/auth/token
Confirm the profile authenticates. `huli auth status` mints a throwaway access token
against `/auth/token` and prints the resolved org and granted scopes without writing any
data.
huli auth status --profile roster-sync
```text
profile: roster-sync
client: roster-sync@clinica-san-rafael
org: Clínica San Rafael (01965e2a-8c4d-7000-9001-0000000000b0)
scopes: system/Patient.rs system/Appointment.rs
token: valid, expires in 4m51s
```
If the scope line is missing `system/Appointment.rs`, the key was minted without it —
re-mint in Practice Settings before scheduling. A scope gap surfaces at runtime as
`HPB-00104`, not at setup.
### 2. Write the sync script
The script walks one resource at a time. It reads the page cursor from a checkpoint file,
calls the CLI for that page, appends the entries, then advances the checkpoint to the
`next` cursor the page returned. Committing the output **before** advancing the checkpoint
is what makes a restart safe: a crash between append and checkpoint-write re-reads the
same page, and the loader in step 5 dedupes on resource `id`.
Save this as `/usr/local/bin/huli-roster-sync.sh`.
```bash
#!/usr/bin/env bash
set -euo pipefail
PROFILE="roster-sync"
STATE_DIR="/var/lib/huli-sync"
RUN_DATE="$(date +%F)" # e.g. 2026-06-02
OUT_DIR="${STATE_DIR}/${RUN_DATE}"
mkdir -p "${OUT_DIR}"
# Walk one FHIR resource type to exhaustion, resuming from a per-resource checkpoint.
sync_resource() {
local resource="$1"
local ckpt="${STATE_DIR}/${resource}.cursor"
local out="${OUT_DIR}/${resource}.ndjson"
# Resume: a non-empty checkpoint means a prior run stopped mid-roster.
local cursor=""
if [[ -s "${ckpt}" ]]; then
cursor="$(cat "${ckpt}")"
echo "[$(date -Iseconds)] ${resource}: resuming from cursor ${cursor:0:16}…"
else
echo "[$(date -Iseconds)] ${resource}: starting fresh"
fi
while :; do
# `huli fhir search` emits one JSON object per line:
# {"page":[...entries...], "next":"|"}
# --cursor "" requests the first page. The CLI handles token refresh,
# 429 Retry-After backoff, and 5xx retries internally.
local page_json
page_json="$(huli fhir search "${resource}" \
--profile "${PROFILE}" \
--count 100 \
--cursor "${cursor}" \
--output ndjson-page)"
# Append this page's entries, then advance the checkpoint. Output is
# committed BEFORE the cursor moves, so a crash here re-reads this page.
jq -c '.page[]' <<<"${page_json}" >>"${out}"
cursor="$(jq -r '.next' <<<"${page_json}")"
if [[ -z "${cursor}" || "${cursor}" == "null" ]]; then
: >"${ckpt}" # roster exhausted: clear checkpoint
echo "[$(date -Iseconds)] ${resource}: complete"
break
fi
printf '%s' "${cursor}" >"${ckpt}" # durable resume point
done
}
sync_resource Patient
sync_resource Appointment
```
Make it executable.
chmod 0755 /usr/local/bin/huli-roster-sync.sh
The CLI absorbs rate-limit and transient-server retries so the loop stays linear. On
`HPB-00105` it reads the `Retry-After` header, sleeps that many
seconds, and re-issues the same page — the cursor does not advance, so no entries are
skipped or doubled. On `5xx` it retries with exponential backoff. It surfaces
(`HPB-00104` insufficient scope) and
(`HPB-00106` auth failed) as a non-zero exit immediately, because those will not clear on
retry.
### 3. Pin the cursor semantics
Three invariants make the resume correct:
- **The cursor is opaque and stable.** Treat `next` as a base64 token — never parse,
truncate, or regenerate it. Passing yesterday's cursor into today's run resumes exactly
where that token points; passing an empty string starts a fresh full walk.
- **Append, then advance.** The script writes the page to NDJSON before it overwrites the
checkpoint. A power loss between those two lines costs you one re-read of a single page
on restart, never a gap. Reversing the order — advancing the checkpoint first — is the
one change that can drop a page; do not do it.
- **The raw NDJSON can repeat a page.** A resume re-reads the in-flight page, and because
the cursor orders by a server-side sort key, rows created mid-walk can re-surface on a
later page. The walk itself does not promise a duplicate-free file — the step-5 id-level
dedup is what makes the final mirror one row per resource. Treat the NDJSON as an
at-least-once stream, not exactly-once.
### 4. Schedule the cron entry
Run nightly at 02:15 in the clinic's timezone. The `CRON_TZ` prefix pins the schedule to
America/Mexico_City regardless of the host clock. A flock guard stops a long run from
overlapping the next night's trigger.
Install it as the `huli-sync` user's per-user crontab. A per-user crontab line has **no
username field** — the schedule goes straight to the command:
```cron
CRON_TZ=America/Mexico_City
15 2 * * * /usr/bin/flock -n /var/lib/huli-sync/.lock /usr/local/bin/huli-roster-sync.sh >> /var/log/huli-roster-sync.log 2>&1
```
Save that as `/etc/huli/roster-sync.crontab` (owned by the cron user) and install it for
the `huli-sync` service user:
crontab -u huli-sync /etc/huli/roster-sync.crontab
A per-user crontab and `/etc/cron.d/` are two different mechanisms — do not mix their
syntax. A `/etc/cron.d/` system-crontab line carries a username field
(`15 2 * * * huli-sync /usr/bin/flock …`) and you install it by dropping the file into
`/etc/cron.d/` with no `crontab` command. A per-user crontab (the form above) omits the
username field and is installed with `crontab -u`. Add a username field to a per-user
crontab and `crontab` misparses `huli-sync` as the command.
`flock -n` makes the job idempotent against overlap: if a run is still going when the
next 02:15 fires, the second invocation exits without starting a parallel walk. Combined
with the per-resource checkpoint, an overrun simply continues on the following night from
where it stopped.
### 5. Load with id-level dedup
A resume re-reads at most the in-flight page, and a mid-walk write can re-surface a row on
a later page. Dedup on the FHIR resource `id` at load time so any repeat collapses to a
single row. This `sort -u`-then-upsert pattern keeps the loader idempotent no matter how
many times a page was re-read.
```bash
for resource in Patient Appointment; do
jq -r '[.id, (.|tojson)] | @tsv' \
"/var/lib/huli-sync/$(date +%F)/${resource}.ndjson" \
| sort -u -k1,1 \
| your-loader upsert --table "fhir_${resource,,}" --key id
done
```
`sort -u -k1,1` keeps the first row per `id`; `your-loader upsert --key id` makes the
database write a no-op when the row already exists. Either layer alone makes the load
idempotent — running both is deliberate redundancy for a cron job you will not be watching.
## What to verify
- `huli auth status --profile roster-sync` resolves the org and lists both
`system/Patient.rs` and `system/Appointment.rs`.
- After a full run, both `Patient.cursor` and `Appointment.cursor` are empty — a
non-empty checkpoint means the walk stopped partway and will resume next start.
- If the first page's searchset Bundle reports `total`, the NDJSON line count for each
resource should approximate it, allowing for one duplicated page from a resume. Treat it
as a sanity check, not an exact reconciliation — a searchset `total` is an estimate the
server may omit or revise across pages.
- After the step-5 load, row counts in `fhir_patient` / `fhir_appointment` equal the
distinct `id` count in the NDJSON — no duplicates survived the dedup.
- Kill the script mid-run (`Ctrl-C` during a page), re-run it, and confirm the output
resumes from the saved cursor rather than restarting from page one.
## What can go wrong
Every error is a FHIR `OperationOutcome`. Branch on the HTTP status and `issue[0].code`
(the FHIR IssueType); the Huli code (`HPB-…`) is the prefix of `issue[0].diagnostics` —
split on `": "` to read it. There is no `details` object and no `coding`.
`HPB-00105` — rate limited. The CLI handles this for you: it
reads `Retry-After`, sleeps, and re-issues the same page without advancing the cursor. If
you ever drive the loop with raw `curl` instead, you must replicate that — honor
`Retry-After` and retry the **same** cursor, never the next one.
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "throttled",
"diagnostics": "HPB-00105: Rate limit exceeded"
}
]
}
```
`HPB-00104` — insufficient scope. The token authenticated but
lacks `system/Appointment.rs` (or `system/Patient.rs`). The CLI exits non-zero
immediately rather than retrying. Re-mint the key in Practice Settings with both `.rs`
scopes selected.
`HPB-00106` — auth failed. The `client_assertion` was rejected
— usually a `client_id`/private-key mismatch or a clock skew that pushed the assertion's
`iat`/`exp` out of tolerance. Re-run `huli auth status` to isolate setup from the loop,
and verify the cron host's clock is NTP-synced.
`HPB-00107` — auth expired. The 5-minute access token lapsed
mid-walk on a slow page. The CLI refreshes automatically before each request; you only see
this if you pinned a stale token by hand. Let the CLI manage the token — never cache the
bearer across pages yourself.
`HPB-00101` — validation error. A search parameter or the
`_cursor` is malformed — most often a checkpoint file that was hand-edited or truncated.
Treat the cursor as opaque: if a checkpoint is corrupt, clear it (`: > Patient.cursor`) to
restart that resource's walk from page one rather than patching the token.
A corrupt or partially-written checkpoint produces `HPB-00101`, not a silent wrong-page
resume. If a crash truncates the checkpoint mid-write, the next run rejects the malformed
cursor loudly. Recover by clearing the file and re-walking from the start — the step-5
id-level dedup absorbs the full re-read without leaving duplicate rows in the mirror.
## Next recipes
- **Authenticate as a SMART Backend Service** — the `client_credentials` +
`private_key_jwt` (RS384) handshake the CLI performs under `huli auth setup`, end to
end, for when you need to drive `/auth/token` yourself.
- **Incremental sync with `_lastUpdated`** — narrow the nightly walk to records changed
since the last run instead of a full pull.
- **Paginate a large patient list** — the cursor / `link[rel=next]` mechanics this loop
rides on, walked by hand against a single searchset.
- **Mirror Encounters and Observations** — extend the same checkpointed loop to the
clinical resources, adding `system/Encounter.rs` and `system/Observation.rs`.
========================================================================
# Debugging a failed FHIR search
# URL: https://developers.huli.ai/v1/recipes/debugging-a-failed-fhir-search
# Read the OperationOutcome a failed FHIR R4 search returns, map each status to its HPB code and fix, and trace the correlation id to the audit record Huli support needs.
# Debugging a failed FHIR search
A FHIR search that returns anything other than `200 OK` hands you a structured
`OperationOutcome`. Read it correctly and you resolve most failures yourself in one pass:
the HTTP status names the category, the body carries the Huli code, and the response
headers carry the correlation id you hand to support when the failure is on our side.
## Audience
You integrate against the Huli FHIR API, you already authenticate (admin bearer token
or SMART Backend Services), and you have a search returning a non-`200` status. You read
JSON without a viewer and you want a repeatable triage path instead of a guess.
## You'll need
- A request that reproduces the failure — the exact URL, method, and the token you sent.
- `curl`, or a Go, Python, or Node HTTP client if you prefer a language client.
- The ability to capture **response headers**, not just the body. `curl -i` or `curl -D -`
prints them; most language clients expose them on the response object.
Every error from the FHIR API is a FHIR `OperationOutcome`, never a bare string and never
the `{ "error": { "code", "message" } }` shape used by Huli's internal APIs. Decode the
body as JSON and read `issue[0]` — that is where the machine-readable signal lives.
## End state
You can take any failed search, classify it from `issue[0].code` and the `HPB-…` prefix in
`issue[0].diagnostics`, apply the documented fix for that class, and — when the cause is
not on your side — extract the correlation id from the response headers and give support
the four facts they need to find the matching audit record.
## Steps
### 1. Capture the response with its headers
Replay the failing search with headers visible. The `-i` flag prints the status line and
all response headers ahead of the body.
```bash
curl -i "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
A failing response looks like this on the wire — status line, headers (correlation id
included), then the `OperationOutcome` body:
```http
HTTP/2 403
content-type: application/fhir+json
x-correlation-id: 01965e9f-2a17-7000-9007-0000000000c4
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
```
### 2. Read the OperationOutcome
The body is always this shape — `resourceType`, then an `issue` array. Each issue carries
exactly three fields. There is no `details`, no `coding`, no `text`.
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
```
Two fields drive your triage:
- `issue[0].code` — the FHIR `IssueType`, derived from the HTTP status: `400` → `invalid`,
`401` → `security`, `403` → `forbidden`, `404` → `not-found`, `409` → `conflict`,
`429` → `throttled`, `5xx` → `exception`.
- `issue[0].diagnostics` — a human-readable message prefixed with the Huli code. The
`HPB-…` code is the prefix; split on `": "` to extract it.
Pull both apart programmatically rather than substring-matching the whole sentence. Split
on the first `": "`: the head is the `HPB-…` code, the tail is the message. If the string
carries no `": "`, treat that as a non-conforming body (see "What can go wrong") rather
than assuming the whole sentence is the code.
:::CodeGroup
```typescript
const url = new URL('https://api.huli.ai/fhir/R4/Patient');
url.searchParams.set('name', 'Fernández');
url.searchParams.set('_count', '20');
const resp = await fetch(url, {
headers: {
Authorization: `Bearer ${API_KEY}`,
Accept: 'application/fhir+json',
},
});
if (resp.status !== 200) {
const outcome = (await resp.json()) as {
issue: { code: string; diagnostics: string }[];
};
const issue = outcome.issue[0];
const fhirCode = issue.code; // e.g. "forbidden"
const diagnostics = issue.diagnostics; // "HPB-00104: Insufficient scope"
const sep = diagnostics.indexOf(': ');
// No "HPB-...: " prefix — unexpected/non-conforming body.
const hpbCode = sep === -1 ? '' : diagnostics.slice(0, sep);
const message = sep === -1 ? diagnostics : diagnostics.slice(sep + 2);
const correlationId = resp.headers.get('X-Correlation-Id');
const retryAfter = resp.headers.get('Retry-After'); // set only on 429
console.log(resp.status, fhirCode, hpbCode, message);
console.log('correlation id:', correlationId);
if (retryAfter) {
console.log('retry after (s):', retryAfter);
}
}
```
```python
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/Patient",
params={"name": "Fernández", "_count": 20},
headers={
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/fhir+json",
},
timeout=30,
)
if resp.status_code != 200:
outcome = resp.json()
issue = outcome["issue"][0]
fhir_code = issue["code"] # e.g. "forbidden"
diagnostics = issue["diagnostics"] # "HPB-00104: Insufficient scope"
hpb_code, sep, message = diagnostics.partition(": ")
if not sep:
# No "HPB-...: " prefix — unexpected/non-conforming body.
hpb_code, message = "", diagnostics
correlation_id = resp.headers.get("X-Correlation-Id")
retry_after = resp.headers.get("Retry-After") # set only on 429
print(resp.status_code, fhir_code, hpb_code, message)
print("correlation id:", correlation_id)
if retry_after:
print("retry after (s):", retry_after)
```
```java
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 ClassifyFhirSearch {
public static void main(String[] args) throws Exception {
// Let the client percent-encode the accent — never pre-encode here.
String name = URLEncoder.encode("Fernández", StandardCharsets.UTF_8);
URI uri = URI.create(
"https://api.huli.ai/fhir/R4/Patient?name=" + name + "&_count=20");
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder()
.uri(uri)
.header("Authorization", "Bearer " + System.getenv("HULI_API_KEY"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse response =
client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
String body = response.body();
// Hand-parse the two fields we triage on; a real client would
// use a JSON library to read issue[0].code / .diagnostics.
String fhirCode = extract(body, "code"); // e.g. "forbidden"
String diagnostics = extract(body, "diagnostics"); // "HPB-00104: Insufficient scope"
int sep = diagnostics.indexOf(": ");
// No "HPB-...: " prefix — unexpected/non-conforming body.
String hpbCode = sep == -1 ? "" : diagnostics.substring(0, sep);
String message = sep == -1 ? diagnostics : diagnostics.substring(sep + 2);
String correlationId =
response.headers().firstValue("X-Correlation-Id").orElse(null);
String retryAfter = // set only on 429
response.headers().firstValue("Retry-After").orElse(null);
System.out.println(response.statusCode() + " " + fhirCode
+ " " + hpbCode + " " + message);
System.out.println("correlation id: " + correlationId);
if (retryAfter != null) {
System.out.println("retry after (s): " + retryAfter);
}
}
}
// Minimal value lookup for a flat "key":"value" — illustrative only.
static String extract(String json, String key) {
String needle = "\"" + key + "\":\"";
int start = json.indexOf(needle);
if (start == -1) {
return "";
}
start += needle.length();
int end = json.indexOf('"', start);
return end == -1 ? "" : json.substring(start, end);
}
}
```
```go
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type operationOutcome struct {
ResourceType string `json:"resourceType"`
Issue []struct {
Severity string `json:"severity"`
Code string `json:"code"`
Diagnostics string `json:"diagnostics"`
} `json:"issue"`
}
func classify(resp *http.Response) {
body, _ := io.ReadAll(resp.Body)
defer resp.Body.Close()
var oo operationOutcome
if err := json.Unmarshal(body, &oo); err != nil || len(oo.Issue) == 0 {
fmt.Printf("non-OperationOutcome body (status %d): %s\n", resp.StatusCode, body)
return
}
issue := oo.Issue[0]
hpbCode, message, found := strings.Cut(issue.Diagnostics, ": ")
if !found {
// No "HPB-...: " prefix — unexpected/non-conforming body.
hpbCode, message = "", issue.Diagnostics
}
fmt.Printf("status=%d fhirCode=%s hpb=%s message=%q\n",
resp.StatusCode, issue.Code, hpbCode, message)
fmt.Printf("correlation-id=%s\n", resp.Header.Get("X-Correlation-Id"))
if ra := resp.Header.Get("Retry-After"); ra != "" {
fmt.Printf("retry-after=%s\n", ra)
}
}
```
:::
### 3. Map the failure to its fix
Match on the status plus the `HPB-…` code, then apply the fix. These four cover the
overwhelming majority of failed searches.
`security` / `HPB-00106` — auth failed. The token is missing,
malformed, or revoked. Confirm the header reads `Authorization: Bearer ` with a
single space, and that the variable holding it is actually populated in this shell. A
second `401`, `HPB-00107` (auth expired), means a SMART Backend Services access token has
passed its 5-minute TTL — mint a fresh one and retry. An admin bearer token does not
expire on a timer, so `HPB-00107` against an admin key usually means a SMART access token
is being sent on a request you intended to authenticate with the admin key.
`forbidden` / `HPB-00104` — insufficient scope. The token
authenticated but lacks the scope this search needs. A `Patient` name search needs
; a write needs .
Re-mint the key in **Practice Settings → Integrations → API Keys** with the read+search
(`rs`) scope selected for every resource you query.
`invalid` / `HPB-00101` — validation error. A search parameter
is malformed or unknown — most often an un-encoded accent or a typo'd parameter name.
URL-encode `á` as `%C3%A1`, and check each parameter against the search reference for that
resource. Note that hand-built `curl` URLs need the literal `%C3%A1`, while language
clients percent-encode for you — passing a pre-encoded value into a client library
double-encodes it and the search matches nothing.
`throttled` / `HPB-00105` — rate limited. You exceeded the
per-key request budget. Read the `Retry-After` response header (in seconds) and back off
for that long before retrying. Add jitter if several workers share one key.
### 4. Surface the correlation id
When the failure is on Huli's side — a `5xx` `exception`, or a `4xx` whose fix you have
already applied and which still fails — the correlation id ties your request to our
server-side record. It is on the response, not the body:
```bash
# Print only the correlation id from a failing request
curl -s -o /dev/null -D - \
"https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json" \
| grep -i '^x-correlation-id:'
```
In a language client, read the `X-Correlation-Id` response header (shown in the Python and
Go samples in step 2). Capture and log it on every non-`200` response as a matter of
course — it is the single fastest way for support to locate your request, and it is gone
once the response is discarded.
### 5. Hand support the audit facts
Each FHIR search Huli serves writes an audit record server-side, keyed by the same
correlation id you captured. Support locates the matching record from the facts you
provide — there is no client-facing audit-lookup endpoint, so give them everything needed
to find it on the first try:
- The **correlation id** from `X-Correlation-Id`.
- The **UTC timestamp** of the request, ISO-8601 with offset (e.g. `2026-06-02T14:22:09-06:00`).
- The **exact URL** you called, including query parameters (redact nothing — the audit
record stores the search parameters and they must match).
- The **`client_id`** of the credential you authenticated with (the API key's client
identifier, not the secret).
Never send your bearer token, client secret, or private key to support. The `client_id`
and correlation id are sufficient to locate the record; the secret material is not needed
and must not leave your environment.
## What to verify
- The response body parses as JSON and `resourceType` is `OperationOutcome`.
- `issue[0].code` matches the HTTP status per the mapping in step 2.
- The `HPB-…` prefix you split out of `issue[0].diagnostics` matches the status
(`HPB-00101`/400, `HPB-00104`/403, `HPB-00105`/429, `HPB-00106`/401, `HPB-00107`/401).
- On a `429`, a `Retry-After` header is present and you honored it before retrying.
- You captured `X-Correlation-Id` before discarding the response.
## What can go wrong
- **Substring-matching the whole `diagnostics` sentence.** The message text can change;
the `HPB-…` prefix and `issue[0].code` are the stable contract. Split on `": "` and
branch on the code, not the prose.
- **A `diagnostics` string with no `HPB-…: ` prefix.** Every conforming error leads with
`HPB-…: `. If your split finds no `": "` separator, you are looking at a non-conforming
or unexpected body (a proxy error page, a truncated response) — fall back to the HTTP
status and the correlation id rather than treating the whole string as a code.
- **Looking for `issue.details` or a `coding` array.** Neither exists on this API. The
only fields on an issue are `severity`, `code`, and `diagnostics`.
- **Reading the correlation id from the request instead of the response.** The id is
assigned server-side and returned on the `X-Correlation-Id` _response_ header. If you
only logged the request, you have nothing to give support.
- **Retrying a `429` immediately.** Without honoring `Retry-After` you compound the
throttle. Back off for the advertised seconds, then retry.
- **Treating `HPB-00107` (auth expired) as `HPB-00106` (auth failed).** Expired means the
credential was valid and timed out — refresh the SMART access token rather than
re-checking the key. The fixes differ.
- **Mixing up the token and discovery hosts.** The token endpoint is host-rooted at
`POST https://api.huli.ai/auth/token`, while SMART discovery and JWKS are issuer-rooted
under `/fhir` (`https://api.huli.ai/fhir/.well-known/smart-configuration` and
`https://api.huli.ai/fhir/.well-known/jwks.json`). A `401` that resists every credential
fix is often a request sent to the wrong path.
## Next recipes
- **Run your first authenticated Patient search** — the green-path single request these
failures are the inverse of.
- **Authenticate as a SMART Backend Service** — `client_credentials` + `private_key_jwt`
(RS384) and the 5-minute token lifecycle behind `HPB-00107`.
- **Paginate a large patient list** — follow the `Bundle.link` entry with
`relation: "next"` once your search returns `200`.
========================================================================
# Fetching a patient's full record
# URL: https://developers.huli.ai/v1/recipes/fetching-a-patient-record
# Pull a patient's whole clinical record — encounters, observations, notes, documents, medications, and orders — in one Patient/$everything Bundle, scope-filtered, with date and type narrowing.
# Fetching a patient's full record
Pull everything you are allowed to see about one patient in a single read. The
`Patient/$everything` operation aggregates the patient's clinical record — `Encounter`,
`Observation`, `Composition`, `DocumentReference`, `MedicationRequest`, and `ServiceRequest`,
plus the `Patient` itself — into one `searchset` `Bundle`. It is **read-only** and
**scope-filtered**: each resource type appears only if your token carries read scope for it, and
the Bundle tells you, in-band, which types it withheld.
This is the fastest way to hydrate a record without orchestrating six separate searches. The
trade-off to understand up front: the result is shaped by your scopes. A token that can read
encounters but not documents gets the encounters and a machine-readable note that documents were
held back — never a silent omission.
## Audience
You build a record-sync, a care-summary view, or a migration that ingests a patient's full chart.
You read a `Bundle` without a viewer, you know what a FHIR reference is, and you want one call that
returns as much of a patient's record as your token is entitled to.
## You'll need
- A bearer token from HuliPractice (**Practice Settings → Integrations → API Keys**), or a
SMART Backend Services access token. See [Bearer Tokens](/v1/auth/bearer) and
[`POST /auth/token`](/v1/auth).
- system/Patient.rs at minimum — the operation gates on patient read. Then add a
read scope for **each** type you want included:
- and (the
BAA-gated **Clinical information** card), plus
and .
- and (the
BAA-gated **Clinical information** card).
A type whose read scope is absent is **withheld**, not an error — the call still succeeds.
- The `id` of the patient. Resolve it with
[a Patient search](/v1/recipes/getting-started-patient-search) if you only hold a name.
- `curl`, or Node, Python, Java, or Go.
`$everything` only ever **reads**. A search-only token (`.s` without `.r`) does not satisfy the
per-type instance-read gate — disclosing a type's instances in the aggregate requires an instance
read grant (`.r`/`.rs`/`.cru`/`.crud`), so a type you can only search is reported as withheld.
## End state
You hold a `200 OK` whose body is a `searchset` `Bundle`. The patient is the first `match` entry;
each clinical resource you are scoped for follows as further `match` entries. If any type was
withheld for lack of scope, the Bundle carries a `meta.tag` of `scope-filtered` and an
`OperationOutcome` entry naming the withheld types.
## Steps
### 1. Export the token and the patient id
```bash
export HULI_TOKEN=""
export PATIENT_ID="01965e2a-8c4d-7000-9001-0000000000a2"
```
### 2. Call $everything
`GET` the operation on the patient instance. Optional parameters narrow the result:
- `start` / `end` — bound the clinical resources to a date window (`YYYY-MM-DD`).
- `_type` — a comma-separated list to include only specific types (e.g.
`_type=Encounter,Observation`). Omit it to include every type you are scoped for.
- `_count` — the per-type page cap (default 50). When a type has more rows than the cap, the
Bundle flags it as truncated.
:::CodeGroup
```bash
curl "https://api.huli.ai/fhir/R4/Patient/01965e2a-8c4d-7000-9001-0000000000a2/\$everything?start=2026-01-01&_count=50" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```typescript
const id = process.env.PATIENT_ID!;
const params = new URLSearchParams({ start: '2026-01-01', _count: '50' });
const resp = await fetch(`https://api.huli.ai/fhir/R4/Patient/${id}/$everything?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
Accept: 'application/fhir+json',
},
});
const bundle = (await resp.json()) as {
entry?: { resource: { resourceType: string }; search?: { mode: string } }[];
meta?: { tag?: { code: string }[] };
};
// match entries are the record; outcome entries carry withheld/truncation notices.
const matches = bundle.entry?.filter((e) => e.search?.mode === 'match') ?? [];
const notices = bundle.entry?.filter((e) => e.search?.mode === 'outcome') ?? [];
console.log(matches.map((e) => e.resource.resourceType));
if (bundle.meta?.tag?.some((t) => t.code === 'scope-filtered')) {
console.log('some types withheld:', notices);
}
```
```python
import os
import requests
patient_id = os.environ["PATIENT_ID"]
resp = requests.get(
f"https://api.huli.ai/fhir/R4/Patient/{patient_id}/$everything",
params={"start": "2026-01-01", "_count": 50},
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
bundle = resp.json()
matches = [e for e in bundle.get("entry", []) if e.get("search", {}).get("mode") == "match"]
notices = [e for e in bundle.get("entry", []) if e.get("search", {}).get("mode") == "outcome"]
print([e["resource"]["resourceType"] for e in matches])
if any(t.get("code") == "scope-filtered" for t in bundle.get("meta", {}).get("tag", [])):
print("some types withheld", notices)
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class PatientEverything {
public static void main(String[] args) throws Exception {
String id = System.getenv("PATIENT_ID");
String url = "https://api.huli.ai/fhir/R4/Patient/" + id
+ "/$everything?start=2026-01-01&_count=50";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + System.getenv("HULI_TOKEN"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
// Parse with a JSON library: match entries are the record, outcome entries
// (search.mode=outcome) carry the withheld/truncation notices.
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
```
```go
package main
import (
"fmt"
"io"
"net/http"
"os"
)
func main() {
id := os.Getenv("PATIENT_ID")
url := "https://api.huli.ai/fhir/R4/Patient/" + id +
"/$everything?start=2026-01-01&_count=50"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+os.Getenv("HULI_TOKEN"))
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)
}
// entry[].search.mode is "match" (the record) or "outcome" (withheld/truncation notices).
fmt.Printf("%d\n%s\n", resp.StatusCode, body)
}
```
:::
First, resolve a real patient id in your sandbox:
Then run `$everything` against it:
### 3. Read the Bundle — matches, and the in-band notices
Every record resource is an `entry` with `search.mode: "match"`; the `total` counts only those.
Any notice — withheld types, truncation — rides as an extra `entry` with
`search.mode: "outcome"` carrying an `OperationOutcome`, and does **not** count toward `total`.
```json
{
"resourceType": "Bundle",
"type": "searchset",
"total": 3,
"meta": {
"tag": [
{ "system": "https://fhir.huli.ai/r4/CodeSystem/bundle-tags", "code": "scope-filtered" }
]
},
"link": [
{
"relation": "self",
"url": "https://api.huli.ai/fhir/R4/Patient/01965e2a-8c4d-7000-9001-0000000000a2/$everything?start=2026-01-01&_count=50"
}
],
"entry": [
{
"fullUrl": "https://api.huli.ai/fhir/R4/Patient/01965e2a-8c4d-7000-9001-0000000000a2",
"resource": { "resourceType": "Patient", "id": "01965e2a-8c4d-7000-9001-0000000000a2" },
"search": { "mode": "match" }
},
{
"fullUrl": "https://api.huli.ai/fhir/R4/Encounter/01965e2a-8c4d-7000-9060-0000000000e9",
"resource": { "resourceType": "Encounter", "id": "01965e2a-8c4d-7000-9060-0000000000e9" },
"search": { "mode": "match" }
},
{
"fullUrl": "https://api.huli.ai/fhir/R4/Observation/01965e2a-8c4d-7000-9080-0000000000a7",
"resource": { "resourceType": "Observation", "id": "01965e2a-8c4d-7000-9080-0000000000a7" },
"search": { "mode": "match" }
},
{
"resource": {
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "information",
"code": "informational",
"diagnostics": "The following resource types were withheld because the access token lacks read scope for them: DocumentReference, MedicationRequest."
}
]
},
"search": { "mode": "outcome" }
}
]
}
```
Three things to lift from the Bundle:
- **The `meta.tag` of `scope-filtered`** is a fast machine signal that the result is incomplete by
scope — check it before treating the Bundle as the whole record.
- **The `information` outcome** names the **withheld** types (type names only — never PHI). Add
the missing read scopes to the key if you need them.
- **A `warning` outcome** (not shown above) names **truncated** types — a type had more rows than
`_count`. Narrow `start`/`end` or raise `_count`, then re-read for the rest. A separate
`warning` flags any type that failed to read (partial results), so a single type's outage never
fails the whole call.
## What to verify
- HTTP status is `200`. `resourceType` is `Bundle`, `type` is
`searchset`.
- The first `match` entry is the `Patient`, and `total` equals the number of `match` entries.
- Every type you hold read scope for is present (within your date window and `_count`).
- If `meta.tag` is `scope-filtered`, the `information` outcome lists exactly the types you did not
scope for.
## 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-00102` — **patient not found.** The id does not name a patient in
your organization (a patient outside your organization is invisible, not forbidden). Confirm the id and
the token's organization.
`HPB-00104` — **insufficient scope.** The token lacks the baseline
system/Patient.rs the operation gates on. (Lacking a _per-type_ read scope does not
cause a `403` — that type is withheld in-band instead.)
`HPB-00101` — **validation error.** The patient id is malformed, or a
parameter is invalid. Use a well-formed UUID and `YYYY-MM-DD` dates.
Withheld and truncated types are **not** errors — the call returns `200` with the partial record
and the in-band notices. Treat the `scope-filtered` tag and the `outcome` entries as the contract
for "what is missing and why", rather than inferring completeness from the absence of an error.
## Next recipes
- **[Writing and amending a clinical note](/v1/recipes/writing-a-clinical-note)** — read or amend
the `Composition` notes that appear in the aggregate.
- **[Uploading a document](/v1/recipes/uploading-a-document)** — add the `DocumentReference`
attachments the aggregate surfaces.
- **[Wiring a read-only partner](/v1/recipes/wiring-a-read-only-partner)** — the per-resource
search alternative when you want to page one type at a time instead of one aggregate read.
========================================================================
# Run your first authenticated Patient search
# URL: https://developers.huli.ai/v1/recipes/getting-started-patient-search
# Send a FHIR R4 Patient name search with an admin bearer token — request, searchset Bundle, and the four errors you'll hit first.
# Run your first authenticated Patient search
Run one authenticated request against the FHIR API, read the `searchset` Bundle it
returns, and recognize the four failures that account for most first-run support
tickets. One request, one round-trip — the whole loop fits in a single terminal session.
Want to try this without a production credential? The [playground](/playground) runs this exact
search against a sandbox organization with fabricated patients — get a key via the
[Sandbox quickstart](/v1/recipes/sandbox-quickstart).
## Audience
You integrate clinical systems and have called a FHIR R4 server before. You know what a
`Bundle` is, you read JSON without a viewer, and you want a single green request before
you wire up the rest of your integration.
## You'll need
- An admin bearer token from HuliPractice (**Settings → Integrations → API Keys**). An
admin-role user on your organization mints it; the token is shown once.
- The system/Patient.rs scope on that token. `rs` grants read plus
search, which is what this request uses. system/Patient.cru also works.
- `curl`, or one of Node 18+ / Python 3.9+ / JDK 11+ / Go 1.22+ if you prefer a language client.
The admin bearer token is a long-lived credential scoped to one organization. It does
not expire on a timer the way SMART Backend Services tokens do. Treat it as a secret:
keep it in an environment variable or a secrets manager, never in source control or a
client bundle.
## End state
You hold a `200 OK` whose body is a FHIR `Bundle` of type `searchset` containing the
`Patient` resources whose name matches your query — for this recipe, Doctora María
Fernández's patients at Clínica San Rafael that match `Fernández`.
## Steps
### 1. Export the token
```bash
export HULI_API_KEY=""
```
Confirm it is set:
```bash
echo $HULI_API_KEY
```
### 2. Run the search
The `name` parameter does a case- and accent-insensitive prefix match across the
patient's name parts. URL-encode the accent (`á` → `%C3%A1`).
:::CodeGroup
```bash
curl "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
```typescript
const params = new URLSearchParams({ name: 'Fernández', _count: '20' });
const resp = await fetch(`https://api.huli.ai/fhir/R4/Patient?${params}`, {
headers: {
Authorization: `Bearer ${process.env.HULI_API_KEY}`,
Accept: 'application/fhir+json',
},
});
console.log(resp.status);
console.log(await resp.json());
```
```python
import os
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/Patient",
params={"name": "Fernández", "_count": 20},
headers={
"Authorization": f"Bearer {os.environ['HULI_API_KEY']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
print(resp.status_code)
print(resp.json())
```
```java
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 PatientSearch {
public static void main(String[] args) throws Exception {
String query = "name=" + URLEncoder.encode("Fernández", StandardCharsets.UTF_8)
+ "&_count=" + URLEncoder.encode("20", StandardCharsets.UTF_8);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/Patient?" + query))
.header("Authorization", "Bearer " + System.getenv("HULI_API_KEY"))
.header("Accept", "application/fhir+json")
.GET()
.build();
HttpResponse response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
```
```go
package main
import (
"fmt"
"io"
"net/http"
"net/url"
"os"
)
func main() {
endpoint, err := url.Parse("https://api.huli.ai/fhir/R4/Patient")
if err != nil {
panic(err)
}
q := endpoint.Query()
q.Set("name", "Fernández")
q.Set("_count", "20")
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)
}
}
```
:::
No Fernández in your sandbox? Drop the `name` filter entirely
(`/fhir/R4/Patient?_count=20`) — every sandbox has *some* patients, just not necessarily this
one. See [your sandbox patients](/playground/roster) for a curated list of real ids to search by.
The TypeScript, Python, Java, and Go clients percent-encode query values for you — pass the
raw accented string (`Fernández`) and let the library encode it. Only hand-built URLs,
like the `curl` above, need the literal `%C3%A1`. Setting `name` to a pre-encoded
`Fern%C3%A1ndez` in a client library double-encodes it to `%25C3%25A1` and the search
matches nothing.
### 3. Read the searchset Bundle
A `200 OK` returns a `Bundle` of type `searchset`. The patients live under
`entry[].resource` and `total` is the match count. When the result set spans more than
one page, the `Bundle` carries a `link` entry with `relation: "next"` holding the cursor;
this two-match example fits on one page, so no `next` link appears.
```json
{
"resourceType": "Bundle",
"id": "01965e2a-8c4d-7000-9001-0000000000a1",
"meta": {
"lastUpdated": "2026-06-01T09:12:44.000-06:00"
},
"type": "searchset",
"total": 2,
"link": [
{
"relation": "self",
"url": "https://api.huli.ai/fhir/R4/Patient?name=Fern%C3%A1ndez&_count=20"
}
],
"entry": [
{
"fullUrl": "https://api.huli.ai/fhir/R4/Patient/01965e2a-8c4d-7000-9001-0000000000a2",
"resource": {
"resourceType": "Patient",
"id": "01965e2a-8c4d-7000-9001-0000000000a2",
"meta": {
"versionId": "4",
"lastUpdated": "2026-05-28T16:03:09.000-06:00",
"profile": ["https://fhir.huli.ai/r4/StructureDefinition/HuliPatient"]
},
"active": true,
"name": [
{
"use": "official",
"family": "Fernández",
"given": ["Ana", "Lucía"],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/second-lastname",
"valueString": "Ramírez"
}
]
}
],
"gender": "female",
"birthDate": "1985-09-22",
"telecom": [
{
"system": "phone",
"value": "+52 33 2145 8890",
"use": "mobile"
}
],
"address": [
{
"use": "home",
"line": ["Calle Morelos 408, Col. Americana"],
"city": "Guadalajara",
"state": "Jalisco",
"postalCode": "44160",
"country": "MX"
}
],
"managingOrganization": {
"reference": "Organization/01965e2a-8c4d-7000-9001-0000000000b0"
}
}
},
{
"fullUrl": "https://api.huli.ai/fhir/R4/Patient/01965e2a-8c4d-7000-9001-0000000000a3",
"resource": {
"resourceType": "Patient",
"id": "01965e2a-8c4d-7000-9001-0000000000a3",
"active": true,
"name": [
{
"use": "official",
"family": "Fernández",
"given": ["Carlos"],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/second-lastname",
"valueString": "Ortega"
}
]
}
],
"gender": "male",
"birthDate": "1991-02-11"
}
}
]
}
```
The second entry (Carlos) is trimmed for brevity to the fields that differ from the
first — `meta`, `telecom`, `address`, and `managingOrganization` are omitted here, not
absent on the wire. The API returns the same resource shape for every `Patient`; only
populated fields appear.
Two name-handling notes for LATAM data:
- The first surname sits in `name.family`. The second surname rides in the
`second-lastname` extension on the same `name` element — read both to reconstruct the
full apellido.
- `gender` is the FHIR value (`male` / `female` / `other`), mapped from Huli's internal
`M` / `F` / `I`. Match on the FHIR token here, not the Huli letter.
## What to verify
- HTTP status is `200`.
- `resourceType` is `Bundle` and `type` is `searchset`.
- `total` matches the number of `entry` items you expected for `Fernández`.
- Each `entry.resource.resourceType` is `Patient`.
- For a two-match query like this one there is no `next` link, so you have the full
result set. Larger queries paginate — see the pagination recipe below.
## What can go wrong
All errors return a FHIR `OperationOutcome`, not a bare string. Branch on the HTTP status
code and `issue[0].code` (the FHIR IssueType) for machine-readable classification. The
Huli-specific code (`HPB-…`) is available as the prefix of `issue[0].diagnostics` — split
on `": "` to extract it. There is no `details` object. These four cover most first-run
failures:
`HPB-00106` — auth failed. The token is missing, malformed,
or revoked. Confirm the header reads `Authorization: Bearer ` with a single
space, and that `$HULI_API_KEY` is actually exported in this shell.
`HPB-00104` — insufficient scope. The token authenticated but
lacks system/Patient.rs . Re-mint it in Practice Settings with `Patient.rs`
(or `Patient.cru`) selected.
`HPB-00101` — validation error. A search parameter is
malformed — most often an un-encoded accent or an unknown parameter. Encode `á` as
`%C3%A1` and check the parameter name against the Patient search reference.
`HPB-00105` — rate limited. You exceeded the per-key request
budget. Read the `Retry-After` response header and back off for that many seconds before
retrying.
A representative `403` body:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
```
There is a second `401`, `HPB-00107` (auth expired), that you will not hit with an admin
bearer token — it applies to the time-limited tokens issued by SMART Backend Services.
If you see it here, you are sending a SMART access token rather than the admin key.
## Next recipes
- **Paginate a large patient list** — follow the `link[rel=next]` cursor to walk every
page of a `searchset`.
- **Search NOM-024 LATAM identifiers** — query the `identifier` parameter for CURP, RFC,
NSS, and INE, with the identifier system URIs defined authoritatively there.
- **Authenticate as a SMART Backend Service** — swap the admin bearer token for
`client_credentials` + `private_key_jwt` (RS384) when you ship a server-to-server
integration.
- **Create and update a Patient** — move from `system/Patient.rs` to
`system/Patient.cru` and `POST` / `PUT` patient records.
========================================================================
# Send lab results to the chart
# URL: https://developers.huli.ai/v1/recipes/posting-lab-observations-lis
# POST a LOINC-coded, UCUM-quantified Observation from your lab system — link it to the Patient and decode the validation errors that block most first writes.
# 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.
Standalone Observation writes are **patient-scoped and out-of-encounter**. `Observation.encounter`
is read-only on the Public API: a create or update that carries it is rejected with `400`.
Encounter-bound results are recorded through the encounter save flow, not as standalone
Observation POSTs — see the note in step 1. The write surface also takes a single
`valueQuantity`; `referenceRange` and `component` are **not read on write** (they are
silently dropped and do not round-trip).
## 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 . `cru` grants create, read,
and update — a write needs the `c`. 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).
- on the same token if your lab system resolves the `Patient`
UUID by search before writing. This recipe assumes you already hold it from the order
message. ( 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.value` and
`valueQuantity.unit`; the LOINC catalog resolves the canonical unit and display
server-side. Send a human unit label (e.g. `mg/dL`) in `valueQuantity.unit`.
- `curl`, or a Go, Python, or Node HTTP client.
LOINC is the `code`; the value is the `valueQuantity`. `code.coding[].system` must be
exactly `http://loinc.org` and the `code` must be one the catalog recognizes — an unknown
LOINC code is a `400` (`HPB-00101`, catalog lookup). The write reads `valueQuantity.value`
and `valueQuantity.unit`; it does **not** validate `valueQuantity.code` (UCUM), so a
missing or mismatched UCUM token does not by itself fail the write. The canonical unit is
resolved from the LOINC catalog regardless of what you send.
## 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
```bash
export HULI_TOKEN=""
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.
```json
{
"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 — `system` exactly `http://loinc.org` and a
`code` the observation catalog recognizes. This is required; a `code` with no LOINC
coding, or an unknown LOINC code, is a `400` (`HPB-00101`).
- The write reads `valueQuantity.value` and `valueQuantity.unit`. The LOINC catalog
resolves the canonical unit and display, so `valueQuantity.code` (UCUM) is not validated
on write — send `system`/`code` for round-trip fidelity if you like, but they do not
gate the write.
- `status` is required, and the write surface accepts only a **subset** of the FHIR value
set: a create persists `final` only — any other status (`registered`, `preliminary`,
`amended`, `corrected`, `cancelled`) is rejected with `422` on `Observation.status`. To
void a stored result, `PUT` it with `entered-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`).
The write surface takes a **single** `valueQuantity` per Observation. `referenceRange` and
`component` are not read by the write decoder — if you send them they are silently dropped
and will not round-trip. Multi-component vitals (e.g. a blood-pressure panel with separate
systolic/diastolic components) are not supported as a single standalone Observation write.
### 3. POST the Observation
Save the body from step 2 to `observation.json`, then:
:::CodeGroup
```bash
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
```
```typescript
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);
}
```
```python
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"])
```
```java
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 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());
}
}
}
```
```go
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:
```bash
curl https://api.huli.ai/fhir/R4/Observation/ \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
## What to verify
- HTTP status is `201`.
- The response body has a server-assigned `id` — a UUID you did not send — and that `id`
round-trips on the follow-up `GET /fhir/R4/Observation/{id}`.
- `code.coding[0].system` is `http://loinc.org` and the LOINC `code` round-trips
unchanged.
- `valueQuantity.value` round-trips and `valueQuantity.system` is
`http://unitsofmeasure.org`. The `unit` reflects the catalog's canonical unit for the
LOINC code, which may differ from the label you sent.
- `subject.reference` resolves to your `PATIENT_ID`. The body carries **no** `encounter`
(a standalone write is out-of-encounter), and no `referenceRange` or `component` (the
write surface does not read them).
- `status` is `final`. There is no `referenceRange`/`component` on 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. Two
lab-integration shapes hit this:
- **Missing / unknown LOINC** (`HPB-02908`): `code` has no coding under `http://loinc.org`,
or the LOINC `code` is 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:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"diagnostics": "HPB-00101: code.coding must include a LOINC code (system http://loinc.org)"
}
]
}
```
— unsupported `status`. A create accepts only `final`; any other
status (`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`.
```json
{
"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\""
}
]
}
```
— also: `Observation.encounter` present on a write. The encounter
is read-only on this surface; remove it (see step 1).
`HPB-00102` — not found. An unresolvable `subject` reference is
rejected. Resolve the patient against `Patient` search before writing, and confirm the
token's organization owns it.
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "not-found",
"diagnostics": "HPB-00102: referenced resource not found"
}
]
}
```
`HPB-00104` — insufficient scope. The token authenticated but
lacks the `c` in . Re-mint it with
`Observation.cru` selected; `Observation.rs` is read-only and cannot write.
`HPB-00106` (auth failed) / `HPB-00107` (auth expired). The
token is missing, malformed, or — on a SMART Backend Services token — past its 5-minute
TTL. For `HPB-00107`, exchange a fresh access token at
and retry. An admin bearer token does not expire on a timer, so `HPB-00107` against one
usually means a SMART access token is being sent on a request you intended to authenticate
with the admin key.
`HPB-00105` — rate limited. You exceeded the per-key request
budget — 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 `Observation` by `patient` + `code` + `date`,
then `PUT` `entered-in-error` to 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.
========================================================================
# Receive webhooks
# URL: https://developers.huli.ai/v1/recipes/receiving-webhooks
# React to appointments and clinical events as they happen — register a rest-hook Subscription, verify the HMAC signature on every delivery, dedupe on the event id, and recover missed events with $replay.
# Receive webhooks
React to appointments and clinical events as they happen, without polling. Stand up an
endpoint that receives Huli's outbound webhooks: register a FHIR R4
`Subscription`, verify the HMAC signature on each delivery, dedupe on the stable event id,
and pull the full resource from the FHIR API. By the end you have a receiver that is safe
against forged, duplicated, and out-of-order deliveries.
For the full model — lifecycle, delivery semantics, and every header — read the
[Webhooks concept](/v1/concepts/webhooks) first. This recipe is the working end-to-end
path.
## Audience
You run an integration that needs to react to appointments and clinical events (a new or
rescheduled appointment, a finalized encounter) without polling. You can host an HTTPS
endpoint and you hold a machine (API key) credential minted with a BAA (Business Associate
Agreement) attestation and the system/Subscription.crud scope.
## You'll need
- A publicly reachable **HTTPS** endpoint. Private, link-local, and metadata IPs are refused
at dial time.
- A machine bearer token with system/Subscription.crud (create) and, for the
observability calls, system/Subscription.rs .
- A read grant on the resource type you subscribe to — this recipe's `criteria: "Encounter"`
needs system/Encounter.rs on the same credential ("subscribe only to what
you can read"; otherwise the create is refused with `403 HPB-00104`).
- A place to store the signing secret returned once at create time.
## End state
A live `Subscription` in `status: active`, an endpoint that verifies `X-Huli-Signature` and
dedupes on `X-Huli-Event-Id`, and a tested `$replay` path for catching up after downtime.
## Steps
### 1. Create the subscription
Notify on finalized (and other) `Encounter` events. `criteria` is the resource **type**
only — filter on the event type in your receiver.
:::CodeGroup
```bash
curl -X POST "https://api.huli.ai/fhir/R4/Subscription" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Subscription",
"status": "requested",
"reason": "Sync encounters into our EHR",
"criteria": "Encounter",
"channel": {
"type": "rest-hook",
"endpoint": "https://hooks.example.com/huli",
"payload": "application/fhir+json"
}
}'
```
```python
import os
import requests
resp = requests.post(
"https://api.huli.ai/fhir/R4/Subscription",
headers={
"Authorization": f"Bearer {os.environ['HULI_API_KEY']}",
"Content-Type": "application/fhir+json",
},
json={
"resourceType": "Subscription",
"status": "requested",
"reason": "Sync encounters into our EHR",
"criteria": "Encounter",
"channel": {
"type": "rest-hook",
"endpoint": "https://hooks.example.com/huli",
"payload": "application/fhir+json",
},
},
timeout=30,
)
print(resp.status_code) # 201
```
:::
The response is with `Cache-Control: no-store`. Its body is the
stored `Subscription` (now `status: active`) with the **signing secret in an extension** and
a non-blocking BAA reminder in `contained[]`.
The signing secret is returned **exactly once**. Read it out of this `201` and store it in
your secrets manager now — no endpoint re-reveals it. If you lose it, delete the
subscription and create a new one.
### 2. Verify the signature on every delivery
Each delivery is a `POST` of a `Bundle` (`type: "history"`) with these headers:
`X-Huli-Signature`, `X-Huli-Event-Id`, `X-Huli-Delivery-Id`, `X-Huli-Event-Type`,
`X-Huli-Occurred-At`, and — on replays — `X-Huli-Replay: true`.
Recompute the HMAC over the **raw body bytes** and constant-time-compare against
`X-Huli-Signature`.
Capture the raw request body **before** any JSON middleware parses it. Re-serializing the
JSON changes the bytes and the signature will never match. In Express, use
`express.raw()`; in Flask, read `request.get_data()`.
:::CodeGroup
```javascript
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.HULI_WEBHOOK_SECRET;
const seen = new Set(); // swap for a durable, persistent store in production
// Raw body — do NOT use express.json() on this route.
app.post('/huli', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.get('X-Huli-Signature') ?? '';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const eventId = req.get('X-Huli-Event-Id');
if (seen.has(eventId)) return res.status(200).end(); // duplicate — ack and skip
seen.add(eventId);
const bundle = JSON.parse(req.body.toString('utf8'));
const entry = bundle.entry[0];
// entry.request.method: POST=created, PUT=updated, DELETE=deleted/cancelled.
// Id-level notification — GET the full resource from the FHIR API here.
console.log(req.get('X-Huli-Event-Type'), entry.request.method, entry.request.url);
res.status(200).end(); // 2xx = delivered
});
app.listen(8080);
```
```python
import hashlib
import hmac
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["HULI_WEBHOOK_SECRET"].encode()
seen = set() # swap for a durable, persistent store in production
@app.post("/huli")
def huli():
raw = request.get_data() # raw bytes, before JSON parsing
digest = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
if not hmac.compare_digest(expected, request.headers.get("X-Huli-Signature", "")):
return "", 401
event_id = request.headers.get("X-Huli-Event-Id")
if event_id in seen: # duplicate — ack and skip
return "", 200
seen.add(event_id)
bundle = request.get_json()
entry = bundle["entry"][0]
# entry["request"]["method"]: POST=created, PUT=updated, DELETE=deleted/cancelled.
# Id-level notification — GET the full resource from the FHIR API here.
print(request.headers.get("X-Huli-Event-Type"), entry["request"]["method"])
return "", 200 # 2xx = delivered
```
:::
Dedupe on `X-Huli-Event-Id`, not `X-Huli-Delivery-Id`. The event id is stable across
retries and replays; the delivery id is fresh on every attempt. Deliveries are
at-least-once, so a durable dedupe store (not the in-memory `Set` above) is required in
production.
### 3. Return the right status
- Return any (or other `2xx`) once you have durably accepted the
event. That marks the delivery delivered.
- Return a `4xx` for a signature that does not verify — the delivery is dropped.
- If your handler throws or times out, let it surface as a `5xx`: Huli retries with
exponential backoff, up to 5 attempts, then dead-letters.
- Do **not** answer with a redirect. A `3xx` is refused and counts as a failure.
After 100 consecutive failures the subscription is auto-paused to `status: error` and stops
receiving events. Once your endpoint is healthy, resume it with a `PUT` setting
`status: "active"`, then [replay](#4-replay-missed-events-after-an-outage) the gap.
### 4. Replay missed events after an outage
Re-enqueue events whose `occurred_at` falls in a time window. Only an `active` subscription
may replay; the window is clamped to the 30-day retention horizon and one call re-enqueues
at most 500 events.
```bash
curl -X POST "https://api.huli.ai/fhir/R4/Subscription/$SUB_ID/\$replay" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "from", "valueInstant": "2026-07-01T00:00:00Z" },
{ "name": "to", "valueInstant": "2026-07-02T00:00:00Z" }
]
}'
```
The response summarizes the run:
```json
{
"resourceType": "Parameters",
"parameter": [
{ "name": "deliveriesQueued", "valueInteger": 142 },
{ "name": "truncated", "valueBoolean": false }
]
}
```
Replayed deliveries carry `X-Huli-Replay: true` and the **original** `X-Huli-Event-Id`, so
the dedupe from step 2 transparently absorbs any overlap. If `truncated` is `true`, call
again with the same window — events with an in-flight replay are skipped, so each call
advances to older events. Repeat until `truncated` is `false`.
### 5. Watch delivery health
Read aggregate health with `$stats` and the recent per-attempt trail with `$deliveries`
(both need system/Subscription.rs ):
```bash
curl "https://api.huli.ai/fhir/R4/Subscription/$SUB_ID/\$stats" \
-H "Authorization: Bearer $HULI_API_KEY"
curl "https://api.huli.ai/fhir/R4/Subscription/$SUB_ID/\$deliveries?_count=50&status=failed" \
-H "Authorization: Bearer $HULI_API_KEY"
```
`$stats` reports `delivered`, `failed`, `pending`, `dead`, `deadLetterDepth`,
`totalAttempts`, `successRate`, and delivery-latency percentiles. `$deliveries` returns one
group per recent attempt (`id`, `status`, `attempts`, `replay`, `eventType`, `occurredAt`,
`lastStatusCode`, …) and never exposes payloads, endpoints, or secrets.
## What can go wrong
`HPB-00101` on create — a `criteria` with a query string
(`Encounter?status=finished`), a `channel.type` other than `rest-hook`, a non-HTTPS
endpoint, or a `channel.payload` that is not `application/fhir+json`. Send a bare resource
type and the required channel fields.
`HPB-00104` — the token lacks
system/Subscription.crud , or was not minted with a BAA attestation. Re-mint
the machine credential with the subscription scope and BAA.
Signature mismatches in step 2 are almost always a **re-serialized body**: verify against
the raw received bytes, not a parsed-and-re-encoded object.
## Related
- [Webhooks](/v1/concepts/webhooks) — the full concept: lifecycle, event catalogue, headers,
and delivery semantics.
- [Rate limiting](/v1/concepts/rate-limiting) — per-subscription delivery throttling protects
your endpoint from backlog floods.
========================================================================
# Registering a patient
# URL: https://developers.huli.ai/v1/recipes/registering-a-patient
# Onboard a patient from an external system into a Mexican clinic — discover the NOM-024 address codes via the terminology ValueSets, then POST a FHIR R4 Patient with CURP/RFC identifiers and the second-lastname extension.
# Registering a patient
Turn an external patient record into a stored `Patient`. You will authenticate, discover the
Mexican address codes the NOM-024 model needs — country, municipality, locality, and the
address-source provenance — through the terminology service, then `POST` the patient with a
LATAM-shaped name, CURP/RFC identifiers, and the discovered address. One scope carries the
whole flow: system/Patient.cru for the write, which also grants the read the MX
terminology ValueSets and CodeSystem are gated behind.
The address codes are the reason for the discovery steps. A Mexican organization rejects an
address whose municipality or locality codes are inconsistent or absent, so the terminology
expansions below hand you values that the write will accept. A non-MX organization can skip the
MX address discovery entirely and send a plain address.
## Audience
You integrate clinical systems and onboard patients from an external EHR, a registration
portal, or a referral intake into a Mexican clinic. You have already run
[your first authenticated search](/v1/recipes/getting-started-patient-search), you read a
`Bundle` without a viewer, and you know what a FHIR reference and an extension are. You want to
take a patient from an external record to a `201 Created`.
## You'll need
- A bearer token from HuliPractice (**Practice Settings → Integrations → API Keys**), or a
SMART Backend Services access token. See [Bearer Tokens](/v1/auth/bearer) for provisioning
and [`POST /auth/token`](/v1/auth) for the token exchange — this recipe assumes you already
hold one.
- This one scope on that token:
- — create `Patient` (`.cru` also grants read +
search). The MX terminology resources — the `mx-country` / `mx-municipality` /
`mx-locality` ValueSets and the `address-source` CodeSystem — enforce a per-url
`system/Patient.rs` at the handler, which `.cru` includes. So this one grant covers
discovery and the write.
- `curl`, or Node, Python, Java, or Go if you prefer a language client.
The terminology resources page on offset pagination (`_count` + `_offset`), not the keyset
`_cursor` that Patient search uses. `_count` defaults to 20 and caps at 100; walk pages by
adding `_offset` in multiples of `_count`. The `mx-municipality` expansion **requires** a
`state` code, and `mx-locality` **requires** both `state` and `municipality` — they scope the
catalog so you get back a usable list rather than the whole country.
## End state
You hold a `201 Created` whose body is the stored `Patient` — with a server-assigned `id`, the
patient's full apellido reconstructed from `family` plus the `second-lastname` extension,
CURP/RFC identifiers under their national systems, and an address carrying the MX state code you
discovered. The patient is then resolvable by name or identifier through Patient search.
## Steps
### 1. Export the token
```bash
export HULI_TOKEN=""
```
A non-MX organization can skip to step 3 and send a plain address (`line`, `city`, `state`,
`postalCode`, `country`) with no MX codes.
### 2. (MX path) Discover the address codes
A Mexican address is built from catalog codes, not free text. Expand three ValueSets in order —
each narrows the next — then read the address-source CodeSystem for the provenance code.
#### Country
The expansion returns `expansion.contains[]`, each a `{system, code, display}`. Pick the
country code you need; for a Mexican address that is the code whose display is `México`.
:::CodeGroup
```bash
curl "https://api.huli.ai/fhir/R4/ValueSet/\$expand?url=https://fhir.huli.ai/r4/ValueSet/mx-country" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```typescript
const url = new URL('https://api.huli.ai/fhir/R4/ValueSet/$expand');
url.searchParams.set('url', 'https://fhir.huli.ai/r4/ValueSet/mx-country');
const resp = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
Accept: 'application/fhir+json',
},
});
const vs = await resp.json();
// expansion.contains[].code is the catalog code; .display is the human name.
const country = vs.expansion?.contains?.find((c: { display: string }) => c.display === 'México');
console.log(country?.code, country?.display);
```
```python
import os
import requests
resp = requests.get(
"https://api.huli.ai/fhir/R4/ValueSet/$expand",
params={"url": "https://fhir.huli.ai/r4/ValueSet/mx-country"},
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Accept": "application/fhir+json",
},
timeout=30,
)
vs = resp.json()
# expansion.contains[].code is the catalog code; .display is the human name.
country = next(c for c in vs["expansion"]["contains"] if c["display"] == "México")
print(country["code"], country["display"])
```
:::
A representative country expansion:
```json
{
"resourceType": "ValueSet",
"url": "https://fhir.huli.ai/r4/ValueSet/mx-country",
"status": "active",
"expansion": {
"total": 1,
"contains": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/mx-country",
"code": "1",
"display": "México"
}
]
}
}
```
#### Municipality
The municipality expansion requires the `state` code. State codes follow the published Mexican
catalog (for example Jalisco is `14`); pass the one your patient lives in.
```bash
curl "https://api.huli.ai/fhir/R4/ValueSet/\$expand?url=https://fhir.huli.ai/r4/ValueSet/mx-municipality&state=14&_count=100" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
Omitting `state` is a `400` — the catalog is too large to expand unscoped. Carry the
municipality `code` you pick into the next expansion.
#### Locality
The locality expansion requires both `state` and `municipality`. Use `filter` to prefix-match a
locality name and keep the page small.
```bash
curl "https://api.huli.ai/fhir/R4/ValueSet/\$expand?url=https://fhir.huli.ai/r4/ValueSet/mx-locality&state=14&municipality=39&filter=Guadalajara" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
#### Address source
Read the address-source CodeSystem to obtain the provenance code that records the address came
from the Mexican normative catalog. The CodeSystem exposes `read` only (no `$expand`).
```bash
curl "https://api.huli.ai/fhir/R4/CodeSystem/address-source" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
```json
{
"resourceType": "CodeSystem",
"url": "https://fhir.huli.ai/r4/CodeSystem/address-source",
"status": "active",
"content": "complete",
"concept": [
{
"code": "mx-normativo-nom024",
"display": "NOM-024 normative address source"
}
]
}
```
The single member `mx-normativo-nom024` is the provenance value. In the IG's address model the
source is carried under the system `https://huli.io/fhir/CodeSystem/address-source` — note that
this provenance system string differs from the `https://fhir.huli.ai/r4/CodeSystem/address-source`
canonical you just read; the read endpoint resolves the code, the IG names the system the stored
value uses. See the [FHIR Implementation Guide](https://developers.huli.ai/fhir/) for the exact
address-source binding.
### 3. POST the Patient
Assemble the discovered values into the create body. The name carries the first surname in
`family` and the maternal/second surname in the `second-lastname` extension on the same name
element. Identifiers go under their published national systems (see the note below). The address
maps the discovered state code onto `address.state`, the municipality/locality onto `city` and
`district`, and the rest of the street address onto `line`.
The body below is the **comprehensive** form — every field the create decoder honors on a
Patient write, not a minimal example. Required fields are flagged inline; everything else is
optional. The **Full field reference** after the example lists each field, whether the decoder
reads it on write, and what it maps to. A minimal write needs only `name[0].given[0]`; everything
else enriches the record.
The identifier system URLs are published authoritatively in the FHIR Implementation Guide — do
not invent them. CURP is `http://www.renapo.gob.mx/curp` (RENAPO) and RFC is
`http://www.sat.gob.mx/rfc` (SAT); the identifier `type.coding` uses
`http://terminology.hl7.org/CodeSystem/v2-0203` (`CURP`, `RFC`). The
[FHIR IG identifiers reference](https://developers.huli.ai/fhir/) is the source of truth for the
full identifier system list.
:::CodeGroup
```bash
curl -i -X POST https://api.huli.ai/fhir/R4/Patient \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{
"resourceType": "Patient",
"active": true,
"name": [
{
"use": "official",
"family": "Hernández",
"given": ["Carlos"],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/second-lastname",
"valueString": "Ramírez"
}
]
}
],
"gender": "male",
"birthDate": "1985-07-20",
"maritalStatus": {
"coding": [
{ "system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", "code": "M" }
]
},
"identifier": [
{
"use": "official",
"system": "http://www.renapo.gob.mx/curp",
"value": "HERC850720HJCRMR04",
"type": {
"coding": [
{ "system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "CURP" }
]
}
},
{
"use": "official",
"system": "http://www.sat.gob.mx/rfc",
"value": "HERC850720AB1",
"type": {
"coding": [
{ "system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "RFC" }
]
}
}
],
"telecom": [
{ "system": "phone", "value": "5533112244", "use": "mobile", "rank": 1 },
{ "system": "email", "value": "carlos.hernandez@example.com", "use": "home" }
],
"address": [
{
"use": "home",
"type": "physical",
"line": ["Calle Morelos 408, Col. Americana"],
"city": "Guadalajara",
"district": "Guadalajara",
"state": "14",
"postalCode": "44160",
"country": "MX"
}
],
"contact": [
{
"relationship": [{ "text": "Madre" }],
"name": { "given": ["María Ramírez"] },
"telecom": [{ "system": "phone", "value": "5599887766", "use": "mobile" }]
}
],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-blood-type",
"valueCode": "O+"
},
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-private-insurance",
"extension": [
{ "url": "provider", "valueString": "Seguros Monterrey" },
{ "url": "policyNumber", "valueString": "POL-99812" },
{ "url": "certificateNumber", "valueString": "CERT-44120" }
]
}
]
}'
```
```typescript
const patient = {
resourceType: 'Patient',
active: true, // optional — false maps to an inactive record; absent defaults to active
name: [
{
use: 'official',
family: 'Hernández', // first surname
given: ['Carlos'], // given[0] is the only strictly required field
extension: [
{
// The maternal/second surname rides this extension on the name element.
url: 'https://fhir.huli.ai/r4/StructureDefinition/second-lastname',
valueString: 'Ramírez',
},
],
},
],
gender: 'male', // male | female | other (unknown is accepted but stored as empty)
birthDate: '1985-07-20',
maritalStatus: {
// coding[0].code is read verbatim (S/M/D/W/P/U/L); display is ignored
coding: [{ system: 'http://terminology.hl7.org/CodeSystem/v3-MaritalStatus', code: 'M' }],
},
identifier: [
{
use: 'official',
system: 'http://www.renapo.gob.mx/curp', // CURP — published in the FHIR IG
value: 'HERC850720HJCRMR04',
type: { coding: [{ system: 'http://terminology.hl7.org/CodeSystem/v2-0203', code: 'CURP' }] },
},
{
use: 'official',
system: 'http://www.sat.gob.mx/rfc', // RFC — published in the FHIR IG
value: 'HERC850720AB1',
type: { coding: [{ system: 'http://terminology.hl7.org/CodeSystem/v2-0203', code: 'RFC' }] },
},
],
telecom: [
// system + value read verbatim; use + rank optional
{ system: 'phone', value: '5533112244', use: 'mobile', rank: 1 },
{ system: 'email', value: 'carlos.hernandez@example.com', use: 'home' },
],
address: [
{
use: 'home', // optional
type: 'physical', // optional
line: ['Calle Morelos 408, Col. Americana'],
city: 'Guadalajara',
district: 'Guadalajara',
state: '14', // the mx-country/state code discovered in step 2
postalCode: '44160',
country: 'MX',
},
],
contact: [
// emergency / guardian contact — only name.given[0], relationship[0].text, and telecom are read
{
relationship: [{ text: 'Madre' }],
name: { given: ['María Ramírez'] },
telecom: [{ system: 'phone', value: '5599887766', use: 'mobile' }],
},
],
extension: [
{
// blood type — valueCode stored verbatim
url: 'https://fhir.huli.ai/r4/StructureDefinition/huli-blood-type',
valueCode: 'O+',
},
{
// private insurance — provider required, policyNumber / certificateNumber optional
url: 'https://fhir.huli.ai/r4/StructureDefinition/huli-private-insurance',
extension: [
{ url: 'provider', valueString: 'Seguros Monterrey' },
{ url: 'policyNumber', valueString: 'POL-99812' },
{ url: 'certificateNumber', valueString: 'CERT-44120' },
],
},
],
};
const resp = await fetch('https://api.huli.ai/fhir/R4/Patient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
'Content-Type': 'application/fhir+json',
Accept: 'application/fhir+json',
},
body: JSON.stringify(patient),
});
if (resp.status === 201) {
const created = (await resp.json()) as { id: string };
console.log('registered', created.id);
} else {
const outcome = (await resp.json()) as { issue: { diagnostics: string }[] };
// The HPB- code is the prefix of issue[0].diagnostics — split on ': '.
const [code] = outcome.issue[0].diagnostics.split(': ', 1);
console.log(resp.status, code, outcome.issue[0].diagnostics);
}
```
```python
import os
import requests
patient = {
"resourceType": "Patient",
"active": True, # optional — False maps to an inactive record
"name": [
{
"use": "official",
"family": "Hernández", # first surname
"given": ["Carlos"], # given[0] is the only strictly required field
"extension": [
{
# The maternal/second surname rides this extension on the name element.
"url": "https://fhir.huli.ai/r4/StructureDefinition/second-lastname",
"valueString": "Ramírez",
}
],
}
],
"gender": "male", # male | female | other
"birthDate": "1985-07-20",
"maritalStatus": {
# coding[0].code read verbatim (S/M/D/W/P/U/L); display ignored
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", "code": "M"}]
},
"identifier": [
{
"use": "official",
"system": "http://www.renapo.gob.mx/curp", # CURP — published in the FHIR IG
"value": "HERC850720HJCRMR04",
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "CURP"}]},
},
{
"use": "official",
"system": "http://www.sat.gob.mx/rfc", # RFC — published in the FHIR IG
"value": "HERC850720AB1",
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "RFC"}]},
},
],
"telecom": [
# system + value read verbatim; use + rank optional
{"system": "phone", "value": "5533112244", "use": "mobile", "rank": 1},
{"system": "email", "value": "carlos.hernandez@example.com", "use": "home"},
],
"address": [
{
"use": "home", # optional
"type": "physical", # optional
"line": ["Calle Morelos 408, Col. Americana"],
"city": "Guadalajara",
"district": "Guadalajara",
"state": "14", # the mx-country/state code discovered in step 2
"postalCode": "44160",
"country": "MX",
}
],
"contact": [
# emergency / guardian contact — only name.given[0], relationship[0].text, telecom read
{
"relationship": [{"text": "Madre"}],
"name": {"given": ["María Ramírez"]},
"telecom": [{"system": "phone", "value": "5599887766", "use": "mobile"}],
}
],
"extension": [
{
# blood type — valueCode stored verbatim
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-blood-type",
"valueCode": "O+",
},
{
# private insurance — provider required, policyNumber / certificateNumber optional
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-private-insurance",
"extension": [
{"url": "provider", "valueString": "Seguros Monterrey"},
{"url": "policyNumber", "valueString": "POL-99812"},
{"url": "certificateNumber", "valueString": "CERT-44120"},
],
},
],
}
resp = requests.post(
"https://api.huli.ai/fhir/R4/Patient",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
},
json=patient,
timeout=30,
)
if resp.status_code == 201:
print("registered", resp.json()["id"])
else:
outcome = resp.json()
# The 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"])
```
```go
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
// family carries the first surname; the second-lastname extension carries
// the maternal surname. Identifier systems are the IG-published CURP/RFC URLs;
// address.state is the mx-country/state code discovered in step 2. Only
// name.given[0] is strictly required — every other field below is optional
// enrichment the create decoder honors.
body := []byte(`{
"resourceType": "Patient",
"active": true,
"name": [{
"use": "official",
"family": "Hernández",
"given": ["Carlos"],
"extension": [{
"url": "https://fhir.huli.ai/r4/StructureDefinition/second-lastname",
"valueString": "Ramírez"
}]
}],
"gender": "male",
"birthDate": "1985-07-20",
"maritalStatus": {
"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v3-MaritalStatus", "code": "M"}]
},
"identifier": [
{
"use": "official",
"system": "http://www.renapo.gob.mx/curp",
"value": "HERC850720HJCRMR04",
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "CURP"}]}
},
{
"use": "official",
"system": "http://www.sat.gob.mx/rfc",
"value": "HERC850720AB1",
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "RFC"}]}
}
],
"telecom": [
{"system": "phone", "value": "5533112244", "use": "mobile", "rank": 1},
{"system": "email", "value": "carlos.hernandez@example.com", "use": "home"}
],
"address": [{
"use": "home",
"type": "physical",
"line": ["Calle Morelos 408, Col. Americana"],
"city": "Guadalajara",
"district": "Guadalajara",
"state": "14",
"postalCode": "44160",
"country": "MX"
}],
"contact": [{
"relationship": [{"text": "Madre"}],
"name": {"given": ["María Ramírez"]},
"telecom": [{"system": "phone", "value": "5599887766", "use": "mobile"}]
}],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-blood-type",
"valueCode": "O+"
},
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-private-insurance",
"extension": [
{"url": "provider", "valueString": "Seguros Monterrey"},
{"url": "policyNumber", "valueString": "POL-99812"},
{"url": "certificateNumber", "valueString": "CERT-44120"}
]
}
]
}`)
req, err := http.NewRequest(http.MethodPost,
"https://api.huli.ai/fhir/R4/Patient", 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)
}
switch resp.StatusCode {
case http.StatusCreated:
fmt.Printf("201 registered\n%s\n", out)
case http.StatusBadRequest: // HPB-00101 structural / CURP-composition / MX locality coherence
fmt.Printf("400 validation\n%s\n", out)
case http.StatusUnprocessableEntity: // CURP needs in-app confirmation (no FHIR channel)
fmt.Printf("422 needs in-app confirmation\n%s\n", out)
default:
fmt.Printf("%d\n%s\n", resp.StatusCode, out)
}
}
```
:::
Run a **minimal** patient write against your sandbox — a create needs only
`name[0].given[0]`, so this is the smallest body the decoder accepts:
A `201 Created` returns the stored `Patient` with a server-assigned `id`. The `family` plus the
`second-lastname` extension round-trip, the identifiers round-trip under their systems, and the
address carries the state code you sent.
#### Full field reference
Every field the Patient create decoder reads on write. "Honored" means the create decoder maps
the field into the stored record; fields not listed (or marked **ignored**) are accepted but not
persisted from your input. Only `name[0].given[0]` is required.
| Field | Req? | Honored on write | Notes |
| ------------------------------------------------------- | ------------ | ----------------- | ------------------------------------------------------------------------------------------------------------- |
| `name[0].given[0]` | **required** | yes | First given name — the only strictly required field (`ValidatePatient`). |
| `name[0].family` | optional | yes | First (paternal) surname. |
| `name[0].extension[]` `…/second-lastname` `valueString` | optional | yes | Maternal / second surname on the same name element. |
| `name[0].use` | optional | ignored | Read endpoint always emits `official`. |
| `active` | optional | yes | `true` → active record, `false` → inactive; absent defaults to active. |
| `gender` | optional | yes | `male`/`female`/`other` map to M/F/I. `unknown` validates but stores empty. |
| `birthDate` | optional | yes | `YYYY-MM-DD`. |
| `maritalStatus.coding[0].code` | optional | yes | One of S/M/D/W/P/U/L (v3-MaritalStatus). `display` is ignored. |
| `identifier[].system` | optional | yes | Resolved against the published system list (CURP/RFC/etc.). An unrecognized system is a `400`. |
| `identifier[].value` | optional | yes | Required when an `identifier` entry is present. |
| `identifier[].type` / `use` | optional | ignored | Type is re-derived from the resolved system on read. |
| `telecom[].system` | optional | yes | `phone` / `email` / etc. |
| `telecom[].value` | optional | yes | The number or address. |
| `telecom[].use` | optional | yes | `home`/`mobile`/`work`. |
| `telecom[].rank` | optional | yes | Preference order (integer). |
| `address[].line[]` | optional | yes | Street address lines. |
| `address[].city` | optional | yes | Carries the locality for an MX address. |
| `address[].district` | optional | yes | Carries the municipality for an MX address. |
| `address[].state` | optional | yes | The MX state code discovered in step 2. |
| `address[].postalCode` | optional | yes | |
| `address[].country` | optional | yes | |
| `address[].use` / `type` | optional | yes | `home`/`work`; `physical`/`postal`. |
| `contact[].name.given[0]` | optional | yes | Emergency / guardian contact name. |
| `contact[].relationship[0].text` | optional | yes | Free-text relationship label. |
| `contact[].telecom[]` | optional | yes | `system`/`value`/`use` per `telecom` above. |
| `extension[]` `…/huli-blood-type` `valueCode` | optional | yes | Blood type, e.g. `O+`. |
| `extension[]` `…/huli-private-insurance` | optional | yes | Nested `provider` (required within the block), `policyNumber`, `certificateNumber`. |
| `managingOrganization` | optional | ignored | Server stamps the token's organization; a supplied reference is validated as a UUID but not used to reassign. |
| `deceasedDateTime` | optional | ignored on create | Deceased status is set through the dedicated deceased flow, not the create decoder. |
## What to verify
- HTTP status is `201`.
- The response body's `resourceType` is `Patient` and it carries a server-assigned `id` — a
UUID you did not send.
- `name[0].family` is the first surname and the `second-lastname` extension on the same name
element carries the maternal surname — read both to reconstruct the full apellido.
- Each `identifier[].system` round-trips unchanged (`http://www.renapo.gob.mx/curp` for CURP),
proof the system was recognized, not dropped.
- `address[0].state` is the MX state code you discovered, and `gender` is the FHIR token
(`male`/`female`/`other`) you sent.
## What can go wrong
All errors return a FHIR `OperationOutcome`, never a bare string — `{severity, code,
diagnostics}`, with no `details` object. Branch on the HTTP status and `issue[0].code` (the
FHIR IssueType); the Huli code is the prefix of `issue[0].diagnostics`, split on `": "` to
extract it. Structural problems — a missing required field, malformed JSON, a bad date —
surface as `HPB-00101`.
`HPB-00101` — **structural validation.** A required field is missing
or malformed: no `given` name, a `birthDate` that is not `YYYY-MM-DD`, a `gender` outside
`male`/`female`/`other`, or an identifier under an unrecognized `system`. Send a valid
`given[0]`, a recognized identifier system (the IG-published CURP/RFC URLs), and a
well-formed date.
**CURP composition validation.** A CURP whose internal composition
is inconsistent — the date segment, the sex letter, or the check digit not matching the rest —
is rejected even when the string is the right length. Compose the CURP correctly from the
patient's own data, or omit it and add it once verified.
**inconsistent MX locality codes.** A Mexican organization rejects an
address whose municipality or locality is incoherent with the state — a municipality that does
not belong to the state you sent, or a locality outside the municipality — validated against the
national municipality/country catalogs. This is why step 2 discovers the codes against the
catalog; re-run the step 2 expansions to pick a consistent state → municipality → locality chain.
**CURP needs in-app confirmation.** A CURP that the app would accept
only after an "inappropriate-word" confirmation is rejected until the patient is created through
the app — the FHIR surface has no confirmation channel. Create the patient in HuliPractice, or
omit the CURP and add it there.
A representative `400` body:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "invalid",
"diagnostics": "HPB-00101: Validation error"
}
]
}
```
A non-MX organization does not need the MX address codes. Drop the `state`/`district` catalog
codes and send a plain address (`line`, `city`, `state` as text, `postalCode`, `country`); the
write still succeeds. The MX discovery in step 2 exists to satisfy the Mexican
address-consistency check, not as a universal requirement.
## Next recipes
- **[Booking an appointment end-to-end](/v1/recipes/booking-an-appointment)** — with the
patient registered, discover a service, practitioner, room, and free slot, then `POST` the
Appointment for them.
- **[Run your first authenticated Patient search](/v1/recipes/getting-started-patient-search)**
— resolve the patient you just created by name or identifier (CURP/RFC) to confirm it
persisted and to fetch its `id` later.
- **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
onboarding flow server-to-server.
========================================================================
# Sandbox quickstart
# URL: https://developers.huli.ai/v1/recipes/sandbox-quickstart
# Get a sandbox organization pre-seeded with fake FHIR data from a Huli admin, receive your bearer credential through a one-time link, and make your first authenticated call.
# Sandbox quickstart
Most recipes in this section assume you already have a Huli organization and an admin
who can mint you a bearer token in Practice Settings. A **sandbox** is the same idea,
pointed at synthetic data: a dedicated organization pre-seeded with fabricated
patients, appointments, encounters, and observations, so you can build and test your
integration before it touches anything that looks like production.
A sandbox is **created for you by someone with a Huli account** — an org admin at the
clinic you're integrating with, or your Huli contact. There is no anonymous self-serve
provisioning endpoint: the person creating the sandbox does it from inside
HuliPractice, and hands you the credential through a one-time link. Your side of the
flow needs nothing but that link and an HTTP client.
## Audience
You're an external developer evaluating the Huli Public FHIR API, building a proof
of concept, or developing an integration that isn't ready for real patient data. You
have a contact at Huli or at a clinic who can create the sandbox for you.
## You'll need
- Someone with an **admin-role HuliPractice login** willing to create the sandbox —
see [the admin's side](#sandbox-quickstart.the-admins-side-creating-a-sandbox) below, which you can
forward to them verbatim.
- The **one-time share link** they send you after creating it.
- `curl` (or any HTTP client). The [`huli` CLI](/v1/cli) works too — the credential is
a plain bearer token, so `--token` is all it needs.
## End state
A dedicated sandbox organization seeded with fabricated clinical data; a bearer API
key bound to that org and to nothing else, valid for **14 days**; and one successful
`GET` against the same FHIR R4 surface every production integration uses.
## The admin's side: creating a sandbox
If you're the org admin or Huli operator creating the sandbox for a developer: a
sandbox is always attached to **one production API key**, from **Practice Settings →
Integrations**.
1. Creating a **new** integration? Leave **"Generar un entorno de pruebas
(recomendado)"** checked in the create wizard — the production key and its paired
sandbox are minted together and disclosed in one share link.
For an **existing** key, open the key row's menu and choose **"Gestionar entorno
de pruebas"** (Manage sandbox), then create the sandbox from there.
2. The sandbox's identity is derived from the production key itself — there is no
email or name to fill in. Managing the sandbox again for the same key reuses the
existing sandbox organization (and its data); refreshing just mints a fresh key —
it never creates a duplicate org.
3. On success you get a **one-time share link** (valid for **24 hours**, single use)
containing the developer's bearer credential. Send that link — not a pasted token —
to the developer over a reasonably private channel. The secret itself is only ever
revealed on the share page, exactly once.
The sandbox organization is created and seeded at that moment — fabricated patients,
practitioners, appointments, encounters, and observations, referentially consistent,
zero real data. The minted key is bound to the sandbox org and can never reach any
other organization's data: the credential _is_ the boundary.
## The developer's side
### 1. Open the share link and capture the token
The link your contact sends looks like `https:///api-keys/share/…`. It
works **once**, then self-destructs; the same page shows the API base URL. Store the
bearer token in a secrets manager or environment variable immediately — nobody,
including the admin who created it, can view it again. If you lose it, ask your
contact to refresh the sandbox from the key's **Gestionar entorno de pruebas** dialog:
you'll get a new key on the same organization.
### 2. Make your first call
The sandbox key is an ordinary bearer credential on the same FHIR R4 surface as
production — no separate sandbox auth mode, no token-exchange handshake:
```bash
export HULI_SANDBOX_TOKEN=""
curl "https://api.huli.ai/fhir/R4/Patient?_count=5" \
-H "Authorization: Bearer ${HULI_SANDBOX_TOKEN}" \
-H "Accept: application/fhir+json"
```
A `200 OK` returns the same FHIR `Bundle` shape as [Start](/v1/start) — just backed by
fabricated data instead of a clinic's real records.
Or run it right here — paste your sandbox token into the [playground](/playground) token bar
above the recipes nav (or on this page's Run button, the first time you use one) and press Run:
With the [`huli` CLI](/v1/cli), pass the token directly:
```bash
huli --token "${HULI_SANDBOX_TOKEN}" fhir patient search --count 5
```
## The sandbox indicator
There's no request-level "sandbox mode" flag to remember or forget — the mode lives in
the credential. A sandbox key can only ever resolve to its own sandbox org, so there's
no header or query param that could accidentally point it at real data. Every response
served by a sandbox key carries two identifying signals — one on the transport, one
inside the payload:
```http
X-Huli-Mode: sandbox
```
```json
{
"resourceType": "Patient",
"meta": {
"tag": [
{
"system": "https://huli.io/tags",
"code": "sandbox",
"display": "Synthetic sandbox data"
}
]
}
}
```
The `meta.tag` is stamped on **every** resource a sandbox key reads or writes — single
resources, search `Bundle`s, and each Bundle entry alike — so even data copied out of a
response (into a fixture, a demo, a bug report) stays self-identifying as synthetic.
Use both as belt-and-suspenders checks in your own logs or tests: neither should ever
appear on a response your production key receives, and both should always appear on a
sandbox key's responses.
## Limits and expiry
Sandbox keys carry ceilings a production key doesn't:
| Ceiling | Default | Enforcement |
| --------------------- | --------------- | ----------------------------------------------------------------------------- |
| Rate limit | 60 req/min | Same 1-minute sliding window as every other key. |
| Volume cap (lifetime) | 10,000 requests | Counted per request; never resets on its own. |
| Key expiry | 14 days | Stamped at minting; an expired key is rejected. The org and its data persist. |
| Keys per sandbox | 5 | Each sandbox-key refresh mints a new key on the same org, up to this cap. |
The volume cap is a lifetime counter, not a per-minute rate — it does not reset on a
timer the way the request-rate limit does. Once you hit it, every further request on
that key returns `HPB-00121`. Resetting a sandbox key's
volume counter and extending a key's expiry are **operator-only** actions — reach out
to your Huli contact if you need a clean run or more time.
Only the **credential** expires — the sandbox organization and its seeded data are
never deleted. When a key lapses (or you simply want a fresh one), your contact opens
the production key's **Gestionar entorno de pruebas** dialog and refreshes the sandbox
key: same org, same data, new key, new one-time link.
## What to verify
- The share link opened exactly once and showed a bearer token plus the base URL.
- `GET /fhir/R4/Patient?_count=5` with the token returns a `200` with a `Bundle`, not
an `OperationOutcome`.
- The response headers include `X-Huli-Mode: sandbox`, and each resource carries the
`meta.tag` with `system: "https://huli.io/tags"` and `code: "sandbox"`.
## What can go wrong
— the token is wrong, or the key **expired** (14 days after
minting). Ask your contact to refresh the sandbox key from the production key's
Manage-sandbox dialog — you'll get a fresh key on the same organization.
`HPB-00105` — the per-key request-rate limit (see
[Rate Limiting](/v1/concepts/rate-limiting)); read `Retry-After`.
`HPB-00121` — the lifetime volume cap is exhausted, not the
per-minute rate. `Retry-After` will not help here; resetting a sandbox key's volume
counter is operator-only — reach out to your Huli contact.
**The share link says it was already used or expired** — share links are single-use
with a 24-hour window. Ask your contact to refresh the sandbox key from the production
key's Manage-sandbox dialog; a new key and a new link are minted, and the existing org
and data are reused.
## Graduating to production
When your integration is ready for real patient data, the path is the standard partner
one: the clinic's admin mints you a **production** key from the same Practice Settings
surface — see [Creating and sharing an API key](/v1/recipes/creating-and-sharing-an-api-key)
— or your Huli contact walks you through the commercial onboarding. Your sandbox keeps
working alongside it; it's a separate organization, so nothing you built against it
needs to change.
## Next recipes
- **[Run your first authenticated Patient search](/v1/recipes/getting-started-patient-search)**
— the same request shape, against a real (non-sandbox) organization.
- **[Creating and sharing an API key as a clinic admin](/v1/recipes/creating-and-sharing-an-api-key)**
— the production-key flow this sandbox flow mirrors.
- **[Rate Limiting](/v1/concepts/rate-limiting)** — the per-key/per-org limits every
key has, sandbox or not.
========================================================================
# Scheduling an administrative meeting
# URL: https://developers.huli.ai/v1/recipes/scheduling-an-administrative-meeting
# Book an internal meeting as a FHIR R4 Appointment with no patient — a required title, optional all-day flag, and external email invitees — against a service whose appointment type is administrative.
# Scheduling an administrative meeting
Book an internal meeting — a staff huddle, a vendor call, a blocked planning hour — as a FHIR R4
`Appointment` that has **no patient**. An administrative meeting is an appointment against a
service whose appointment type is `administrative`; it carries a **title**, may run **all day**,
and may invite **external email attendees** who are not Huli users. The scopes are the booking
ones: system/Appointment.cru for the write and system/Practitioner.rs
for the practitioner wiring.
The shape differs from a clinical booking in three load-bearing ways, and the server enforces all
three: an administrative meeting **requires** a title, **rejects** a patient participant, and
**may** carry external invitees — while a clinical appointment rejects the title and invitees and
expects a patient. Pick the right service and the rest follows.
## Audience
You build an internal scheduling tool, a calendar sync, or an operations integration that books
non-clinical time on a practitioner's calendar. You have already
[booked a clinical appointment](/v1/recipes/booking-an-appointment) — this recipe reuses that
recipe's discovery (service, practitioner, room, slot) and changes only the create body.
## You'll need
- A bearer token from HuliPractice (**Practice Settings → Integrations → API Keys**), or a
SMART Backend Services access token. See [Bearer Tokens](/v1/auth/bearer) and
[`POST /auth/token`](/v1/auth).
- These two scopes on that token:
- — create `Appointment` (and the gated discovery
resources `HealthcareService`, `Location`, `Schedule`, `Slot`).
- — read + search `Practitioner` and `PractitionerRole`.
- A **service configured as an administrative meeting type**, plus a practitioner and a
free slot — discovered exactly as in
[Booking an appointment end-to-end](/v1/recipes/booking-an-appointment) (steps 2–5 there). The
one difference is the service: pick one whose appointment type is `administrative`. A room
(`Location`) participant is **optional** for administrative meetings — clinical bookings require
one, administrative meetings do not.
- `curl`, or Node, Python, Java, or Go.
The appointment type is **derived from the service**, not sent by you. To find which service is
administrative, search existing meetings with `GET /fhir/R4/Appointment?appointment-type=administrative`
and read the `serviceType` off one, or confirm with the clinic admin which catalog entry is the
meeting type. A `huli-appointment-title` sent against a *clinical* service is rejected, and a
clinical booking with **no** patient against an *administrative* service is rejected for a missing
title — the two kinds are mutually exclusive.
## End state
You hold a `201 Created` whose body is the stored `Appointment` — no patient participant, a
`huli-appointment-title` extension carrying the meeting name, the practitioner participant (plus a
room participant if you sent one), and (if you sent them) `huli-appointment-external-attendee`
extensions for the email invitees. It is searchable with `appointment-type=administrative`.
**Invitees are emailed automatically.** Creating this meeting emails every
`huli-appointment-external-attendee` a calendar (`ICS`) **REQUEST**; cancelling it sends a
**CANCEL**, and editing it diffs the attendee set (added invitees get a REQUEST, removed ones a
CANCEL, and a time change re-invites the rest). It's the same dispatch the in-app app uses, sent
post-commit and at-least-once — you don't need to send your own invites.
## Steps
### 1. Export the token
```bash
export HULI_TOKEN=""
```
### 2. Find the administrative service and a slot
Discover the bookable values exactly as in
[Booking an appointment end-to-end](/v1/recipes/booking-an-appointment): the `serviceType` coding
(from `HealthcareService.type`), a practitioner and their room (`PractitionerRole`), and a free
`Slot`. Pick a service whose appointment type is `administrative`.
To confirm a service is the administrative one, list existing administrative meetings — the
`appointment-type` token filters on the service's derived type:
```bash
curl "https://api.huli.ai/fhir/R4/Appointment?appointment-type=administrative&_count=20" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
Each match carries its `serviceType` coding and its `huli-appointment-title` extension — copy the
`serviceType` of one to book another meeting against the same service.
### 3. POST the administrative Appointment
Assemble the discovered `serviceType`, the slot's `start`/`end`, and the practitioner + room
participants — **with no `Patient` participant**. Add the required `huli-appointment-title`
extension, an optional `huli-appointment-all-day` boolean, and one
`huli-appointment-external-attendee` extension per email invitee (each nests a required `email`
and an optional `displayName`; up to 30).
:::CodeGroup
```bash
curl -i -X POST https://api.huli.ai/fhir/R4/Appointment \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{
"resourceType": "Appointment",
"status": "booked",
"description": "Revisión mensual de operaciones",
"serviceType": [
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000fa",
"display": "Reunión administrativa"
}
]
}
],
"start": "2026-06-18T15:00:00.000-06:00",
"end": "2026-06-18T16:00:00.000-06:00",
"participant": [
{
"actor": { "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1" },
"status": "accepted"
},
{
"actor": { "reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1" },
"status": "accepted"
}
],
"extension": [
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-appointment-title",
"valueString": "Revisión mensual de operaciones"
},
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-appointment-all-day",
"valueBoolean": false
},
{
"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-appointment-external-attendee",
"extension": [
{ "url": "email", "valueString": "proveedor@ejemplo.com" },
{ "url": "displayName", "valueString": "Proveedor Externo" }
]
}
]
}'
```
```typescript
const ext = 'https://fhir.huli.ai/r4/StructureDefinition';
const meeting = {
resourceType: 'Appointment',
status: 'booked',
description: 'Revisión mensual de operaciones',
serviceType: [
{
coding: [
{
system: 'https://fhir.huli.ai/r4/CodeSystem/org-service',
code: '01965e2a-8c4d-7000-9010-0000000000fa', // an administrative-type service
display: 'Reunión administrativa',
},
],
},
],
start: '2026-06-18T15:00:00.000-06:00',
end: '2026-06-18T16:00:00.000-06:00',
participant: [
// No Patient participant — an administrative meeting rejects one.
{
actor: { reference: 'Practitioner/01965e2a-8c4d-7000-9001-0000000000c1' },
status: 'accepted',
},
{ actor: { reference: 'Location/01965e2a-8c4d-7000-9020-0000000000a1' }, status: 'accepted' },
],
extension: [
{ url: `${ext}/huli-appointment-title`, valueString: 'Revisión mensual de operaciones' }, // required
{ url: `${ext}/huli-appointment-all-day`, valueBoolean: false }, // optional
{
// optional — repeat per invitee, up to 30
url: `${ext}/huli-appointment-external-attendee`,
extension: [
{ url: 'email', valueString: 'proveedor@ejemplo.com' }, // required within the block
{ url: 'displayName', valueString: 'Proveedor Externo' }, // optional
],
},
],
};
const resp = await fetch('https://api.huli.ai/fhir/R4/Appointment', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
'Content-Type': 'application/fhir+json',
Accept: 'application/fhir+json',
},
body: JSON.stringify(meeting),
});
if (resp.status === 201) {
console.log('booked', (await resp.json()).id);
} else {
const outcome = (await resp.json()) as { issue: { diagnostics: string }[] };
console.log(resp.status, outcome.issue[0].diagnostics);
}
```
```python
import os
import requests
ext = "https://fhir.huli.ai/r4/StructureDefinition"
meeting = {
"resourceType": "Appointment",
"status": "booked",
"description": "Revisión mensual de operaciones",
"serviceType": [
{
"coding": [
{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000fa", # an administrative-type service
"display": "Reunión administrativa",
}
]
}
],
"start": "2026-06-18T15:00:00.000-06:00",
"end": "2026-06-18T16:00:00.000-06:00",
"participant": [
# No Patient participant — an administrative meeting rejects one.
{"actor": {"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"}, "status": "accepted"},
{"actor": {"reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1"}, "status": "accepted"},
],
"extension": [
{"url": f"{ext}/huli-appointment-title", "valueString": "Revisión mensual de operaciones"}, # required
{"url": f"{ext}/huli-appointment-all-day", "valueBoolean": False}, # optional
{
# optional — repeat per invitee, up to 30
"url": f"{ext}/huli-appointment-external-attendee",
"extension": [
{"url": "email", "valueString": "proveedor@ejemplo.com"}, # required within the block
{"url": "displayName", "valueString": "Proveedor Externo"}, # optional
],
},
],
}
resp = requests.post(
"https://api.huli.ai/fhir/R4/Appointment",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
},
json=meeting,
timeout=30,
)
print(resp.status_code, resp.json().get("id") or resp.json()["issue"][0]["diagnostics"])
```
```java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class AdminMeeting {
public static void main(String[] args) throws Exception {
// No Patient participant; the title extension is required; all-day and
// external-attendee extensions are optional. Hand-built JSON keeps this
// dependency-free; a real client would use a JSON library.
String ext = "https://fhir.huli.ai/r4/StructureDefinition/";
String body = "{"
+ "\"resourceType\":\"Appointment\",\"status\":\"booked\","
+ "\"description\":\"Revisión mensual de operaciones\","
+ "\"serviceType\":[{\"coding\":[{"
+ "\"system\":\"https://fhir.huli.ai/r4/CodeSystem/org-service\","
+ "\"code\":\"01965e2a-8c4d-7000-9010-0000000000fa\"}]}],"
+ "\"start\":\"2026-06-18T15:00:00.000-06:00\","
+ "\"end\":\"2026-06-18T16:00:00.000-06:00\","
+ "\"participant\":["
+ "{\"actor\":{\"reference\":\"Practitioner/01965e2a-8c4d-7000-9001-0000000000c1\"},\"status\":\"accepted\"},"
+ "{\"actor\":{\"reference\":\"Location/01965e2a-8c4d-7000-9020-0000000000a1\"},\"status\":\"accepted\"}],"
+ "\"extension\":["
+ "{\"url\":\"" + ext + "huli-appointment-title\",\"valueString\":\"Revisión mensual de operaciones\"},"
+ "{\"url\":\"" + ext + "huli-appointment-external-attendee\",\"extension\":["
+ "{\"url\":\"email\",\"valueString\":\"proveedor@ejemplo.com\"},"
+ "{\"url\":\"displayName\",\"valueString\":\"Proveedor Externo\"}]}]}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.huli.ai/fhir/R4/Appointment"))
.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 response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode());
System.out.println(response.body());
}
}
```
```go
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
)
func main() {
// No Patient participant; the title extension is required; all-day and
// external-attendee extensions are optional.
body := []byte(`{
"resourceType": "Appointment",
"status": "booked",
"description": "Revisión mensual de operaciones",
"serviceType": [{"coding": [{
"system": "https://fhir.huli.ai/r4/CodeSystem/org-service",
"code": "01965e2a-8c4d-7000-9010-0000000000fa"
}]}],
"start": "2026-06-18T15:00:00.000-06:00",
"end": "2026-06-18T16:00:00.000-06:00",
"participant": [
{"actor": {"reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1"}, "status": "accepted"},
{"actor": {"reference": "Location/01965e2a-8c4d-7000-9020-0000000000a1"}, "status": "accepted"}
],
"extension": [
{"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-appointment-title", "valueString": "Revisión mensual de operaciones"},
{"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-appointment-all-day", "valueBoolean": false},
{"url": "https://fhir.huli.ai/r4/StructureDefinition/huli-appointment-external-attendee", "extension": [
{"url": "email", "valueString": "proveedor@ejemplo.com"},
{"url": "displayName", "valueString": "Proveedor Externo"}
]}
]
}`)
req, err := http.NewRequest(http.MethodPost,
"https://api.huli.ai/fhir/R4/Appointment", 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)
}
fmt.Printf("%d\n%s\n", resp.StatusCode, out)
}
```
:::
A `201 Created` returns the stored meeting. The `huli-appointment-title` and (when `true`)
`huli-appointment-all-day` extensions round-trip on every read; the
`huli-appointment-external-attendee` extensions round-trip on a single-resource `GET`, create, and
update — but **never on a search**, because invitee emails are PII held off the search keyset path.
An **all-day** meeting (`huli-appointment-all-day: true`) must span whole local days — the service
enforces midnight-to-midnight bounds, so set `start`/`end` to the local day boundaries. A partial-
day window with the all-day flag set is rejected.
### 4. Edit invitees and flip a meeting later
A `PUT` is a full replace. Supplying `huli-appointment-external-attendee` extension(s) **replaces**
the invitee set; omitting them leaves it unchanged. Because a `PUT` replaces everything, you can
flip a meeting between administrative and clinical: omit the title (clears it) and add a `Patient`
participant to turn a meeting into a clinical appointment against a clinical service, or the
reverse.
## What to verify
- HTTP status is `201`. The response `resourceType` is `Appointment`
with a server-assigned `id`.
- There is **no** `Patient` participant, and the practitioner + room participants are present.
- The `huli-appointment-title` extension round-trips with your meeting name.
- The meeting appears in `GET /fhir/R4/Appointment?appointment-type=administrative`.
- A single-resource `GET` shows the `huli-appointment-external-attendee` extensions; the same
resource in a search result does not.
## What can go wrong
All errors return a FHIR `OperationOutcome` — `{severity, code, diagnostics}`, no `details`
object. Structural problems surface as `HPB-00101`; the booking preconditions (practitioner, room,
slot conflict) surface the practice-layer `HP-008xx` codes documented in
[Booking an appointment end-to-end](/v1/recipes/booking-an-appointment).
**Missing title on an administrative meeting.** A meeting against an
administrative service requires `huli-appointment-title`; omitting it is rejected with
`issue[0].code` `required` and an expression of `Appointment.extension(huli-appointment-title)`.
Add the title extension.
**Title or invitees on a clinical appointment.** A
`huli-appointment-title` or `huli-appointment-external-attendee` against a _clinical_ service is
rejected with a `value` issue — those fields belong only to administrative meetings. Either drop
them or book against an administrative service.
**Patient participant on an administrative meeting.** An administrative
meeting must not name a `Patient` participant. Remove it (administrative meetings are internal —
the external-attendee extensions carry guests instead).
**Booking precondition / specialty.** The same guards as a clinical
booking apply: the practitioner must have the room in their assigned locations, the slot must be
free (`409` `HP-00803`), and a multi-specialty service still needs an `Appointment.specialty`
selection (`HPB-00115`/`HPB-00116`). See the booking recipe's
[What can go wrong](/v1/recipes/booking-an-appointment).
## Next recipes
- **[Booking an appointment end-to-end](/v1/recipes/booking-an-appointment)** — the clinical
counterpart, with the full discovery flow this recipe reuses.
- **[Wiring a read-only partner](/v1/recipes/wiring-a-read-only-partner)** — consume the
appointment feed read-only, filtering administrative vs clinical with `appointment-type`.
========================================================================
# Uploading a document
# URL: https://developers.huli.ai/v1/recipes/uploading-a-document
# Attach a lab PDF, scan, or image to a patient as a FHIR R4 DocumentReference — multipart $upload or inline base64 — then read it back via a 30-minute signed URL. Uses the BAA-gated system/DocumentReference.cru scope.
# Uploading a document
Attach a binary — a lab result PDF, a scanned referral, an imaging file — to a patient as a FHIR
R4 `DocumentReference`, then read it back through a short-lived signed download URL. You will
upload by two routes (multipart for a real file on disk, inline base64 for a small payload you
already hold in memory), read the document, search a patient's documents, and soft-delete a
mistake. One scope carries the flow: system/DocumentReference.cru — create, read,
and update.
The binary is never stored in or returned from the resource body. On read, the document's bytes
are served via a **30-minute signed URL** on `content[0].attachment.url`; the resource itself
only carries metadata. That split is the thing to internalize: you `$upload` bytes once, then
every later read hands you a fresh, expiring URL to fetch them.
## Audience
You integrate a lab, an imaging system, or a document pipeline that pushes files into a patient's
chart. You read a `Bundle` without a viewer, you can build a `multipart/form-data` request or
base64-encode a file, and you want a document from disk to a stored, retrievable
`DocumentReference`.
## You'll need
- A bearer token from HuliPractice (**Practice Settings → Integrations → API Keys**), or a
SMART Backend Services access token. See [Bearer Tokens](/v1/auth/bearer) for provisioning
and [`POST /auth/token`](/v1/auth) for the token exchange.
- The system/DocumentReference.cru scope on that token. It sits under the
**Clinical information** 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 upload + read +
update; system/DocumentReference.rs alone grants read + search.
- The `id` of the patient the document belongs to. Resolve it with
[a Patient search](/v1/recipes/getting-started-patient-search) if you only hold a name.
- A file to upload. Allowed types are **PDF, JPEG, PNG, WEBP, and DICOM**, up to **25 MB**.
- `curl`, or Node or Python if you prefer a language client.
The server detects the real content type from the file's **magic bytes** and requires the
filename extension to match — so the **filename is required** on every upload, and a `.pdf` whose
bytes are actually a PNG is rejected. There is **no `DELETE`** verb: retire a document by `PUT`ing
`status: "entered-in-error"`, which soft-deletes it.
## End state
You hold a `201 Created` whose body is the stored `DocumentReference` — patient as `subject`, a
server-detected `content[0].attachment.contentType`, the file size and SHA-256 hash, and (on a
later read) a signed `url` you can `GET` to download the bytes for the next 30 minutes.
## Steps
### 1. Export the token and the patient id
```bash
export HULI_TOKEN=""
export PATIENT_ID="01965e2a-8c4d-7000-9001-0000000000a2"
```
### 2. Upload the file (multipart)
For a real file on disk, `POST` to the `$upload` operation as `multipart/form-data`. The form
takes a `file` part (the binary), a `subject` field (a `Patient` reference or bare UUID), and an
optional `encounter` field to link the document to a visit.
:::CodeGroup
```bash
curl -i -X POST https://api.huli.ai/fhir/R4/DocumentReference/\$upload \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json" \
-F "file=@resultados-laboratorio.pdf;type=application/pdf" \
-F "subject=Patient/01965e2a-8c4d-7000-9001-0000000000a2" \
-F "encounter=Encounter/01965e2a-8c4d-7000-9060-0000000000e9"
```
```typescript
import { readFile } from 'node:fs/promises';
const bytes = await readFile('resultados-laboratorio.pdf');
const form = new FormData();
form.set('file', new Blob([bytes], { type: 'application/pdf' }), 'resultados-laboratorio.pdf');
form.set('subject', `Patient/${process.env.PATIENT_ID}`);
form.set('encounter', 'Encounter/01965e2a-8c4d-7000-9060-0000000000e9'); // optional
const resp = await fetch('https://api.huli.ai/fhir/R4/DocumentReference/$upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.HULI_TOKEN}`,
Accept: 'application/fhir+json',
// Do NOT set Content-Type — fetch sets the multipart boundary for you.
},
body: form,
});
if (resp.status === 201) {
const created = (await resp.json()) as { id: string };
console.log('uploaded', created.id);
} else {
const outcome = (await resp.json()) as { issue: { diagnostics: string }[] };
console.log(resp.status, outcome.issue[0].diagnostics);
}
```
```python
import os
import requests
with open("resultados-laboratorio.pdf", "rb") as fh:
resp = requests.post(
"https://api.huli.ai/fhir/R4/DocumentReference/$upload",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Accept": "application/fhir+json",
},
files={"file": ("resultados-laboratorio.pdf", fh, "application/pdf")},
data={
"subject": f"Patient/{os.environ['PATIENT_ID']}",
"encounter": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9", # optional
},
timeout=60,
)
if resp.status_code == 201:
print("uploaded", resp.json()["id"])
else:
print(resp.status_code, resp.json()["issue"][0]["diagnostics"])
```
:::
A `201 Created` returns the stored `DocumentReference` with a server-assigned `id` and a
`Location` header. The bytes are referenced, not inlined — `content[0].attachment.url` holds a
30-minute signed URL on the create/read response:
```json
{
"resourceType": "DocumentReference",
"id": "01965e2a-8c4d-7000-9070-0000000000f4",
"meta": {
"versionId": "1769472901000000000",
"lastUpdated": "2026-06-26T10:15:01.000-06:00",
"profile": ["https://fhir.huli.ai/r4/StructureDefinition/HuliDocumentReference"]
},
"status": "current",
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2", "type": "Patient" },
"author": [
{ "reference": "Practitioner/01965e2a-8c4d-7000-9001-0000000000c1", "type": "Practitioner" }
],
"date": "2026-06-26T10:15:01.000-06:00",
"content": [
{
"attachment": {
"contentType": "application/pdf",
"url": "https://storage.googleapis.com/huli-prod-documents/...&X-Goog-Expires=1800&...",
"size": 248913,
"hash": "k3m2Q9c0Vp9d1xQe3rJh8oH2bW5sQ0aZ7tC4uN6vY8=",
"title": "resultados-laboratorio.pdf"
}
}
],
"context": {
"encounter": [
{ "reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9", "type": "Encounter" }
]
}
}
```
`contentType` is **server-detected from the bytes**, not echoed from your form — proof the magic-
byte check ran. `hash` is the base64 of the file's SHA-256 digest.
### 3. Upload inline (base64) — the small-payload alternative
When you already hold the bytes in memory, skip multipart and `POST` a JSON `DocumentReference`
with the binary base64-encoded in `content[0].attachment.data`. This works on both
`POST /DocumentReference` and the `$upload` operation. The `title` (filename) is **required** so
the extension can be matched against the detected type.
:::CodeGroup
```bash
curl -i -X POST https://api.huli.ai/fhir/R4/DocumentReference \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{
"resourceType": "DocumentReference",
"status": "current",
"subject": { "reference": "Patient/01965e2a-8c4d-7000-9001-0000000000a2" },
"context": {
"encounter": [ { "reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9" } ]
},
"content": [
{
"attachment": {
"contentType": "application/pdf",
"title": "resultados-laboratorio.pdf",
"data": "JVBERi0xLjQKJ..."
}
}
]
}'
```
```python
import base64
import os
import requests
with open("resultados-laboratorio.pdf", "rb") as fh:
data = base64.standard_b64encode(fh.read()).decode("ascii")
document = {
"resourceType": "DocumentReference",
"status": "current",
"subject": {"reference": f"Patient/{os.environ['PATIENT_ID']}"}, # required
"context": {"encounter": [{"reference": "Encounter/01965e2a-8c4d-7000-9060-0000000000e9"}]}, # optional
"content": [
{
"attachment": {
"contentType": "application/pdf",
"title": "resultados-laboratorio.pdf", # required — extension matched to detected type
"data": data, # required — base64 of the bytes
}
}
],
}
resp = requests.post(
"https://api.huli.ai/fhir/R4/DocumentReference",
headers={
"Authorization": f"Bearer {os.environ['HULI_TOKEN']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
},
json=document,
timeout=60,
)
print(resp.status_code, resp.json().get("id") or resp.json()["issue"][0]["diagnostics"])
```
:::
Prefer multipart for anything beyond a few hundred kilobytes: base64 inflates the payload ~33%
and counts against the same 25 MB ceiling once decoded. Inline mode is convenient for small,
in-memory payloads; multipart streams the file without the encoding overhead.
### 4. Read the document and download the bytes
Read the `DocumentReference` by id to get a **fresh** signed URL, then `GET` that URL to download.
The signed URL expires after 30 minutes — fetch it shortly after the read, and re-read for a new
one rather than caching it.
```bash
# 1. Read the resource to get a fresh signed URL.
URL=$(curl -s "https://api.huli.ai/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json" \
| jq -r '.content[0].attachment.url')
# 2. Download the bytes (the signed URL needs no Authorization header).
curl -s "$URL" -o resultados-laboratorio.pdf
```
### 5. Search a patient's documents
`DocumentReference` search is patient-scoped, so the `patient` parameter is **required**. Narrow
with `category`, `type` (a LOINC code), or `date`. Search results carry the metadata but **no
signed URL** — read the individual document (step 4) when you need the bytes.
```bash
curl "https://api.huli.ai/fhir/R4/DocumentReference?patient=01965e2a-8c4d-7000-9001-0000000000a2&category=laboratory&_count=20" \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Accept: application/fhir+json"
```
`category` codes come from the `document-category` CodeSystem — `laboratory`, `imaging`,
`clinical_note`, `prescription`, `administrative`, `consent`, `growth_booklet`, `other`. Search
pages with `_count` + `_cursor` (follow the `next` link); the `category` filter repaginates with an
exact total, while `type` and `date` filter the current page.
### 6. Correct mistakes — metadata edit and soft-delete
A `PUT` either updates the document's metadata (`category`, `description`) or — when the body sets
`status: "entered-in-error"` — soft-deletes it. The binary itself is immutable on this surface;
to replace the file, upload a new document.
```bash
# Soft-delete a document uploaded against the wrong patient.
curl -i -X PUT https://api.huli.ai/fhir/R4/DocumentReference/01965e2a-8c4d-7000-9070-0000000000f4 \
-H "Authorization: Bearer $HULI_TOKEN" \
-H "Content-Type: application/fhir+json" \
-H "Accept: application/fhir+json" \
-d '{ "resourceType": "DocumentReference", "status": "entered-in-error" }'
```
A `200 OK` returns the document with `status: "entered-in-error"`; it then drops out of reads and
searches.
## What to verify
- The upload is `201`. The response `resourceType` is
`DocumentReference` with a server-assigned `id` and a `Location` header.
- `content[0].attachment.contentType` is the **detected** type (e.g. `application/pdf`) and
`size`/`hash` are populated.
- A read returns a fresh `content[0].attachment.url`, and a `GET` on that URL downloads the bytes.
- The document appears in a `patient`-scoped search; after an `entered-in-error` `PUT`, it no
longer does.
## 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-00119` — **document too large.** The file exceeds the 25 MB
ceiling. Compress or split it; the limit is enforced on the decoded bytes, so a base64 inline
payload hits it sooner than its wire size suggests.
`HPB-00120` — **content invalid or type mismatch.** The bytes are not
one of PDF/JPEG/PNG/WEBP/DICOM, or the filename extension does not match the detected type (a
`.pdf` whose magic bytes are a PNG). Send the true file with its real extension.
`HPB-00101` — **validation error.** A required field is missing — the
multipart `file` or `subject`, or, inline, `content[0].attachment.data` or `.title` (filename).
Add the missing field.
`HPB-00104` — **insufficient scope.** The token lacks
system/DocumentReference.cru (or `.rs` for a read). Because the **Clinical information** card is BAA-gated, confirm the key was minted with a BAA attestation.
`HPB-00118` — **DocumentReference not found.** The id does not name a
document in your organization, or it was soft-deleted. Confirm the id and the token's
organization.
**Document storage not configured.** This server has no document
storage wired; the surface fails closed rather than accepting an upload it cannot persist. This is
an operator-side gap, not a request error.
A representative `413` body:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "processing",
"diagnostics": "HPB-00119: Document exceeds the maximum allowed size"
}
]
}
```
## Next recipes
- **[Writing and amending a clinical note](/v1/recipes/writing-a-clinical-note)** — the
`Composition` narrative that sits alongside these documents under the Clinical information card.
- **[Fetching a patient's full record](/v1/recipes/fetching-a-patient-record)** — pull a patient's
documents together with their encounters, observations, and notes in one `$everything` Bundle.
- **[Creating a clinical encounter](/v1/recipes/creating-an-encounter)** — create the visit you
link a document to via `context.encounter`.
========================================================================
# Wire a read-only partner — appointments and encounters
# URL: https://developers.huli.ai/v1/recipes/wiring-a-read-only-partner
# Give an analytics or reporting tool least-privilege read access to the Appointment + Encounter feed with system/Appointment.rs and system/Encounter.rs — search, read, paginate, and resolve the Practitioner/Organization references they point at. Request only .rs scopes.
# 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:
- — read plus search on `Appointment`.
- — 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.
The admin bearer token is a long-lived credential scoped to one organization. It does not
expire on a timer the way SMART Backend Services access tokens do. Treat it as a secret:
keep it in an environment variable or a secrets manager, never in source control or a client
bundle. When you ship server-to-server, swap it for SMART Backend Services
(`client_credentials` + `private_key_jwt`, RS384, 5-minute access token) — the scopes and
request shapes in this recipe are identical.
This partner is read-only in v1. Request only `.rs` scopes — never `.c`, `.u`, or `.cru` on
any resource. A token minted with only `.rs` scopes cannot write even by accident, which is
the posture you want for a feed consumer.
## End state
You hold two `searchset` `Bundle`s — 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
```bash
export HULI_API_KEY=""
```
Confirm it is set:
```bash
echo $HULI_API_KEY
```
### 2. Search appointments over a date window
`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](https://developers.huli.ai/fhir/).
:::CodeGroup
```bash
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"
```
```typescript
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());
```
```python
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())
```
```java
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 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);
}
}
```
```go
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.
```json
{
"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
`Encounter` search accepts `patient`, `date`, `status`, and `class`. The same `date`-prefix
rule applies. To pull every encounter for one patient, pass `patient=` instead of (or
alongside) the date window.
```bash
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`:
```json
{
"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.
```bash
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
`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.
```bash
curl "https://api.huli.ai/fhir/R4/Practitioner/01965e2a-8c4d-7000-9001-0000000000c1" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
```json
{
"resourceType": "Practitioner",
"id": "01965e2a-8c4d-7000-9001-0000000000c1",
"active": true,
"name": [
{
"use": "official",
"family": "Fernández",
"given": ["María"],
"prefix": ["Dra."]
}
]
}
```
```bash
curl "https://api.huli.ai/fhir/R4/Organization/01965e2a-8c4d-7000-9001-0000000000b0" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Accept: application/fhir+json"
```
```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.
```json
{
"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:
:::CodeGroup
```typescript
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`);
```
```python
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")
```
```java
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 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);
}
}
```
```go
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.
- 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`.
`HPB-00106` — auth failed. The token is missing, malformed, or
revoked. Confirm the header reads `Authorization: Bearer ` 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 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`.
`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](https://developers.huli.ai/fhir/).
`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`.
`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:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "forbidden",
"diagnostics": "HPB-00104: Insufficient scope"
}
]
}
```
This is a read-only partner in v1. A token carrying only `.rs` scopes returns
`HPB-00104` on any write attempt — there is no create or update
path for this integration. If your pipeline has a write step, gate it off here; the v1 feed
is a pull-only source.
## 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.
========================================================================
# Writing and amending a clinical note
# URL: https://developers.huli.ai/v1/recipes/writing-a-clinical-note
# Create a FHIR R4 Composition — the LOINC-sectioned clinical narrative of a visit — then amend it safely with If-Match optimistic concurrency. Uses the BAA-gated system/Composition.cru scope.
# 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](/v1/recipes/creating-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](/v1/auth/bearer) for provisioning
and [`POST /auth/token`](/v1/auth) 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](/v1/recipes/getting-started-patient-search) and a
[Practitioner search](/v1/recipes/creating-an-encounter) if you only hold names.
- `curl`, or Node, Python, Java, or Go if you prefer a language client.
A `Composition` and an `Encounter` are two projections of **one** stored visit. Creating a
`Composition` creates a new visit row; the same row is readable as an `Encounter`. The two own
different fields — `Composition` maps the narrative sections, `Encounter` maps the visit
envelope and the diagnoses — so a `Composition` write preserves whatever the `Encounter` surface
set, and vice versa. There is **no `_history`, no `vread`, and no `DELETE`** on `Composition`;
correct a note's lifecycle through `status` on a `PUT`.
## 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
```bash
export HULI_TOKEN=""
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.
| 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 |
`status` is the FHIR Composition document status and it drives the underlying visit state:
`preliminary` → an in-progress visit, `final` → a completed visit, `entered-in-error` → a voided
visit. `amended` keeps the visit completed. The **diagnosis** section (LOINC `29308-4`) is
**read-only** here — it is emitted on read but ignored on write; set diagnoses through the
[Encounter](/v1/recipes/creating-an-encounter) surface, and a `Composition` `PUT` preserves them.
:::CodeGroup
```bash
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": "Cefalea de 3 días."
}
},
{
"title": "Physical findings",
"code": { "coding": [ { "system": "http://loinc.org", "code": "29545-1" } ] },
"text": {
"status": "generated",
"div": "TA 120/80. Sin focalización."
}
},
{
"title": "Plan of care",
"code": { "coding": [ { "system": "http://loinc.org", "code": "18776-5" } ] },
"text": {
"status": "generated",
"div": "Analgésico y control en 1 semana."
}
}
]
}'
```
```typescript
const section = (code: string, title: string, text: string) => ({
title,
code: { coding: [{ system: 'http://loinc.org', code }] },
text: {
status: 'generated',
div: `${text}`,
},
});
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);
}
```
```python
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'{text}',
},
}
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"])
```
```java
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\":\"Cefalea de 3 días.\"}}"
+ "]}";
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 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());
}
}
```
```go
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": "Cefalea de 3 días."}
}
]
}`)
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/""`, 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.
```json
{
"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": "Cefalea de 3 días."
}
}
]
}
```
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.
```bash
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.
:::CodeGroup
```bash
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": "Cefalea de 3 días, ya resuelta."
}
},
{
"code": { "coding": [ { "system": "http://loinc.org", "code": "18776-5" } ] },
"text": {
"status": "generated",
"div": "Alta. Sin necesidad de control."
}
}
]
}'
```
```typescript
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`. 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 `": "`.
`HPB-00103` — **version 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.
`HPB-00104` — **insufficient scope.** The token lacks
system/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.
`HPB-00117` — **Composition 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.
`HPB-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.
**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:
```json
{
"resourceType": "OperationOutcome",
"issue": [
{
"severity": "error",
"code": "conflict",
"diagnostics": "HPB-00103: Version conflict"
}
]
}
```
## Next recipes
- **[Creating a clinical encounter](/v1/recipes/creating-an-encounter)** — set the visit envelope
and diagnoses (the `Encounter` projection of this same row).
- **[Uploading a document](/v1/recipes/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](/v1/recipes/fetching-a-patient-record)** — pull every
Composition, Encounter, Observation, and document for a patient in one `$everything` Bundle.
========================================================================
# Reference
# URL: https://developers.huli.ai/v1/reference
# OpenAPI specification, FHIR CapabilityStatement, and generated endpoint pages for the Huli Public API v1.
# Reference
Machine-readable contracts for the Huli Public API v1.
## OpenAPI specification
The OpenAPI 3.0.3 specification covers the FHIR R4 endpoints (see the [API reference](/v1/api)).
**Live spec:**
```bash
curl https://api.huli.ai/openapi.json
```
The published `openapi.json` is the source of truth for the API contract — the
endpoint reference under [API](/v1/api) is generated from it.
The spec includes `x-huli-scope` extensions on each operation documenting the required
scope.
## FHIR CapabilityStatement
The live CapabilityStatement reflects what the server currently supports. It is
generated from the same source as the OpenAPI spec — they do not diverge.
```bash
curl https://api.huli.ai/fhir/R4/metadata \
-H "Accept: application/fhir+json"
```
This endpoint does not require authentication. It is safe to fetch on startup to
validate that the server supports the operations your integration needs.
## Generated endpoint pages
The pages under [`/v1/api/`](/v1/api) are auto-generated from the OpenAPI spec and the
server FHIR CapabilityStatement at build time. Each resource page merges the endpoint
reference (request/response schemas, required scopes, error codes, code samples) with the
FHIR narrative (supported interactions, search parameters, custom extensions, and
LATAM-specific identifiers). Browse them from the [API reference index](/v1/api), and use
the R4 / R5 switch there to pick a FHIR release — see [Choosing R4 vs R5](/v1/api/fhir-versions).
## SMART discovery
```bash
curl https://api.huli.ai/fhir/.well-known/smart-configuration
```
Returns the `token_endpoint`, `jwks_uri`, `scopes_supported`, and
`grant_types_supported` needed to configure any SMART-compatible client library.
## JWKS
The server's public key for verifying issued access tokens:
```bash
curl https://api.huli.ai/fhir/.well-known/jwks.json
```
========================================================================
# Scopes
# URL: https://developers.huli.ai/v1/scopes
# SMART on FHIR scope reference for the Huli Public API v1.
# Scopes
This page is hand-curated from the OpenAPI spec. Treat it as the authoritative reference — it matches the scopes advertised in the `/fhir/.well-known/smart-configuration` endpoint.
Scopes follow the SMART on FHIR `system/.` format. Request only the scopes your integration requires.
## Permission letters
- **r** — read a single resource by ID
- **s** — search (query with parameters, paginated Bundle response)
- **c** — create a new resource
- **u** — update an existing resource
- **d** — delete a resource
## Scope reference
### Patient
#### system/Patient.rs
**Read and search patients**
Allows reading individual Patient records by ID and searching the patient list. Does not permit creating or modifying patient data.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/Patient.cru
**Create, read, and update patients**
Allows creating new Patient records, reading existing ones, and updating demographic or contact information. Includes all operations of `system/Patient.rs`.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### Appointment
#### system/Appointment.rs
**Read and search appointments**
Allows reading individual Appointment resources and searching the appointment list by patient, practitioner, date, or status.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/Appointment.cru
**Create, read, and update appointments**
Allows booking new appointments, reading existing ones, and updating appointment status (e.g. cancellation). Includes all operations of `system/Appointment.rs`.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### Encounter
#### system/Encounter.rs
**Read and search encounters**
Allows reading individual Encounter records (clinical consultations) and searching by patient, date, status, or class.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/Encounter.cru
**Create, read, and update encounters**
Allows creating new Encounter records, reading existing ones, and updating encounter status or details. Includes all operations of `system/Encounter.rs`.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### Observation
#### system/Observation.rs
**Read and search observations**
Allows reading individual Observation resources (vital signs, lab results, exam findings) and searching by patient, encounter, LOINC code, date, status, or category.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/Observation.cru
**Create, read, and update observations**
Allows recording new Observation values, reading existing ones, and amending previously recorded observations. LOINC codes are validated on write.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### MedicationRequest
#### system/MedicationRequest.rs
**Read and search medication requests**
Allows reading individual MedicationRequest resources (prescription orders) and searching by patient or encounter.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/MedicationRequest.c
**Create medication requests**
Allows creating MedicationRequest resources (each auto-links to a draft prescription).
Permissions granted: **c** — create a new resource
#### system/MedicationRequest.cru
**Create, read, and update medication requests**
Allows creating, reading, and updating MedicationRequest resources. Updates are read-then-merge and preserve app-only fields; a signed or cancelled prescription cannot be modified.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### ServiceRequest
#### system/ServiceRequest.rs
**Read and search service requests**
Allows reading individual ServiceRequest resources (study / procedure orders) and searching by patient or encounter.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/ServiceRequest.c
**Create service requests**
Allows creating ServiceRequest resources (a single-item order).
Permissions granted: **c** — create a new resource
#### system/ServiceRequest.cru
**Create, read, and update service requests**
Allows creating, reading, and updating ServiceRequest resources. Updates are read-then-merge on draft orders and preserve app-only fields; a signed or cancelled order cannot be modified, and multi-item orders are read-only.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### Composition
#### system/Composition.rs
**Read and search clinical notes**
Allows reading individual Composition resources (the clinical-note projection of an encounter) and searching by patient, date, or type.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/Composition.cru
**Create, read, and update clinical notes**
Allows creating Composition resources, reading existing ones, and updating the clinical narrative (with optimistic concurrency via If-Match). Includes all operations of `system/Composition.rs`.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### DocumentReference
#### system/DocumentReference.rs
**Read and search document references**
Allows reading individual DocumentReference resources (lab results, imaging, scanned files; the binary is served via a 30-minute signed URL) and searching by patient, category, type, or date.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/DocumentReference.cru
**Create, read, and update document references**
Allows uploading document binaries ($upload — base64 inline or multipart, 25 MB ceiling), reading existing ones, and updating their metadata. Includes all operations of `system/DocumentReference.rs`.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource
### Practitioner
#### system/Practitioner.rs
**Read practitioners**
Allows reading individual Practitioner resources (healthcare providers). Practitioner data is read-only in the public API.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
### Organization
#### system/Organization.rs
**Read organization**
Allows reading the Organization resource that corresponds to your API key organization. Organization data is read-only in the public API.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
### Subscription
#### system/Subscription.rs
**Read and search webhook subscriptions**
Allows reading a webhook Subscription by ID and listing the subscriptions registered for your API key organization.
Permissions granted: **r** — read a single resource by ID, **s** — search (query with parameters, paginated Bundle response)
#### system/Subscription.crud
**Manage webhook subscriptions**
Allows creating, reading, updating, and deleting outbound-webhook Subscriptions. Requires a signed BAA on the API key. Includes all operations of `system/Subscription.rs`.
Permissions granted: **c** — create a new resource, **r** — read a single resource by ID, **u** — update an existing resource, **d** — delete a resource
## Requesting scopes
Include the `scope` parameter in your token request. Separate multiple scopes with spaces:
```bash
curl -X POST https://api.huli.ai/auth/token \
-d "grant_type=client_credentials" \
-d "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
-d "client_assertion=${CLIENT_ASSERTION}" \
-d "scope=system/Patient.rs system/Appointment.rs"
```
If `scope` is omitted, all scopes registered for the API key are granted. The issued token includes the granted scopes in the `scope` response field.
========================================================================
# Start building
# URL: https://developers.huli.ai/v1/start
# Run a real FHIR search in your browser with no key, and learn to read the response — success and failure — with a guided walkthrough.
# Start building
The Huli Public API is a FHIR REST API — standard healthcare resources like
`Patient`, `Appointment`, and `Observation` over plain HTTPS. Run the patient
search below right now — no key, it uses sample data baked into the page — and
the response comes back with a walkthrough attached. Flip on **Simulate an
error** to see a failure explained the same way.
## Try it & read the response
Every error is a FHIR `OperationOutcome` resource carrying a stable `HPB-`
code, never an ad-hoc error blob. [Errors](/v1/errors) lists every code, and
the [debugging recipe](/v1/recipes/debugging-a-failed-fhir-search) walks a
failing search end-to-end.
When you're ready for live responses, the playground runs real requests from
your browser against a sandbox organization.