# FHIR API guide: REST interactions, search, Bundles and auth for FHIR R4 integration

> A practical FHIR API reference for integration engineers: RESTful interactions with curl examples, search and paging, transaction Bundles, conditional writes, OperationOutcome errors, SMART backend services and Subscriptions, checked against R4, R5 and the R6 ballot.

Author: Ala Ben Aicha

Canonical page: https://alabenaicha.me/insights/hl7-fhir-integration-guide

Updated: 2026-10-06

## Direct answer

A FHIR API is a REST interface over typed healthcare resources such as Patient, Encounter and Observation. You read and write resources with plain HTTP at `[base]/[type]/[id]`, search with query parameters, group writes in a Bundle, and learn what a given server actually supports from its CapabilityStatement at `GET [base]/metadata`. Most production FHIR APIs run FHIR R4 (4.0.1) and are protected with OAuth 2.0 through SMART on FHIR.

## What FHIR is, and what "FHIR API" usually means

FHIR (Fast Healthcare Interoperability Resources) is the HL7 standard that models health data as resources. Each resource type has a defined structure, JSON and XML representations, and its own search parameters. The [RESTful API](https://hl7.org/fhir/R4/http.html) is one of several exchange paradigms in the specification (documents, messages and services are the others), and it is the one most teams mean when they say "FHIR API". For how FHIR relates to HL7 v2 messages and CDA documents, start with [HL7 vs FHIR](https://alabenaicha.me/insights/digital-health-interoperability-101).

A FHIR integration still needs an agreed clinical workflow, an implementation guide, stable identifiers and an authorization model. A server accepting valid JSON is only the starting point.

## Pick the FHIR version before writing code

| Release | Version                   | Published                    | Status                                                 | Use it when                                                                            |
| ------- | ------------------------- | ---------------------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------- |
| R4      | 4.0.1                     | 30 Oct 2019                  | First normative content plus trial use                 | Default for production and for most national implementation guides                     |
| R4B     | 4.3.0                     | 28 May 2022                  | Trial-use changes only; R4 normative content unchanged | A specific implementation guide is built on it                                         |
| R5      | 5.0.0                     | 26 Mar 2023                  | Trial use                                              | You need native topic-based Subscriptions or an R5 guide, and your partners support R5 |
| R6      | 6.0.0 (not yet published) | Second full ballot July 2026 | Draft                                                  | Track it; HL7 intends most core resources to become normative                          |

Dates come from the [FHIR version directory](https://hl7.org/fhir/directory.html). In R4, Patient, Observation, Bundle, CapabilityStatement and OperationOutcome are normative, while Encounter and Questionnaire remain trial use, so check the maturity of every resource you depend on. In the United States, the ONC certification criterion [§170.315(g)(10)](https://www.healthit.gov/test-method/standardized-api-patient-and-population-services) requires FHIR R4.0.1 with US Core and SMART App Launch 2.0.0, which is one reason R4 stays the safe default. The [FHIR versions page](https://build.fhir.org/versions.html) explains the R4/R4B/R5 relationships.

## Discover the server: CapabilityStatement and SMART configuration

Every conformant server publishes a [CapabilityStatement](https://hl7.org/fhir/R4/capabilitystatement.html). Read it before you design the client.

```bash
curl -s https://fhir.example.org/r4/metadata \
  -H "Accept: application/fhir+json"
```

Check `fhirVersion`, then for each resource the supported `interaction` codes, `searchParam` entries, `supportedProfile`, and the `conditionalCreate` and `conditionalUpdate` flags. Server-wide operations appear under `operation`. OAuth endpoints are advertised at `[base]/.well-known/smart-configuration`. Cache the result, but re-read it after vendor upgrades: a missing search parameter is cheaper to discover here than in production logs.

## The RESTful interactions

| Interaction         | HTTP request                              | Notes                                                              |
| ------------------- | ----------------------------------------- | ------------------------------------------------------------------ |
| read                | `GET [base]/Patient/demo-p001`            | Returns the current version and an `ETag`                          |
| vread               | `GET [base]/Patient/demo-p001/_history/2` | One specific version                                               |
| update              | `PUT [base]/Patient/demo-p001`            | Replaces the whole resource; send `If-Match` to avoid lost updates |
| patch               | `PATCH [base]/Patient/demo-p001`          | JSON Patch, XML Patch or FHIRPath Patch in R4; support varies      |
| delete              | `DELETE [base]/Patient/demo-p001`         | A later read returns `410 Gone` on servers that keep history       |
| create              | `POST [base]/Patient`                     | Server assigns the id and returns `Location`                       |
| search              | `GET [base]/Patient?identifier=...`       | Returns a `searchset` Bundle                                       |
| history             | `GET [base]/Patient/demo-p001/_history`   | Also at type and system level                                      |
| capabilities        | `GET [base]/metadata`                     | The CapabilityStatement                                            |
| batch / transaction | `POST [base]`                             | Body is a Bundle                                                   |

Reads and history in practice:

```bash
BASE=https://fhir.example.org/r4

curl -s "$BASE/Patient/demo-p001" \
  -H "Accept: application/fhir+json" \
  -H "Authorization: Bearer $TOKEN"

curl -s "$BASE/Patient/demo-p001/_history/2" \
  -H "Accept: application/fhir+json" \
  -H "Authorization: Bearer $TOKEN"

curl -s "$BASE/Patient/demo-p001/_history" \
  -H "Accept: application/fhir+json" \
  -H "Authorization: Bearer $TOKEN"
```

Writes, with version awareness and a partial update:

```bash
curl -s -X PUT "$BASE/Patient/demo-p001" \
  -H "Content-Type: application/fhir+json" \
  -H 'If-Match: W/"2"' \
  -H "Prefer: return=representation" \
  -H "Authorization: Bearer $TOKEN" \
  -d @patient-demo-p001.json

curl -s -X PATCH "$BASE/Patient/demo-p001" \
  -H "Content-Type: application/json-patch+json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '[{"op":"replace","path":"/birthDate","value":"1990-01-02"}]'
```

The PUT fails with 412 if someone changed the resource after version 2; the PATCH changes one element without resending the whole resource. `Prefer: return=minimal` skips the response body, which matters for high-volume feeds.

## Search: parameters, \_include, \_revinclude and paging

Search parameters are defined per resource. Token parameters take `system|code`, dates take prefixes such as `ge` and `le`, and modifiers such as `:exact` and `:missing` refine matching. The three requests below return one patient's HbA1c results since January (newest first, 50 per page), a patient found by business identifier together with every Observation that points to it, and encounters since 1 October with their patients. Let curl encode the values:

```bash
curl -s -G "$BASE/Observation" \
  --data-urlencode "subject=Patient/demo-p001" \
  --data-urlencode "code=http://loinc.org|4548-4" \
  --data-urlencode "date=ge2026-01-01" \
  --data-urlencode "_sort=-date" \
  --data-urlencode "_count=50" \
  -H "Accept: application/fhir+json" \
  -H "Authorization: Bearer $TOKEN"

curl -s -G "$BASE/Patient" \
  --data-urlencode "identifier=https://example.org/demo-hospital/patients|DEMO-P001" \
  --data-urlencode "_revinclude=Observation:subject" \
  -H "Accept: application/fhir+json" \
  -H "Authorization: Bearer $TOKEN"

curl -s -G "$BASE/Encounter" \
  --data-urlencode "date=ge2026-10-01" \
  --data-urlencode "_include=Encounter:subject" \
  -H "Accept: application/fhir+json" \
  -H "Authorization: Bearer $TOKEN"
```

`_include` pulls in resources the matches point to; `_revinclude` pulls in resources that point at the matches. Both cost server time, and many servers cap or reject them on large result sets. For queries that should not appear in access logs, use `POST [base]/Observation/_search` with a form-encoded body, as described in the [search specification](https://hl7.org/fhir/R4/search.html).

The response is a `searchset` Bundle:

```json
{
  "resourceType": "Bundle",
  "type": "searchset",
  "total": 132,
  "link": [
    { "relation": "self", "url": "https://fhir.example.org/r4/Observation?subject=Patient/demo-p001&_count=50" },
    { "relation": "next", "url": "https://fhir.example.org/r4/Observation?page-token=opaque-value-from-server" }
  ],
  "entry": [
    {
      "fullUrl": "https://fhir.example.org/r4/Observation/obs-001",
      "resource": { "resourceType": "Observation", "id": "obs-001" },
      "search": { "mode": "match" }
    }
  ]
}
```

Paging rules that prevent real bugs: follow the `next` link exactly as given instead of rebuilding the query; expect the server to return fewer entries than `_count`; do not assume `total` is present, because computing it is expensive (`_total=accurate` asks for it); and separate `search.mode` values `match` and `include` before counting results.

## Observation and Patient resources

Observation carries lab results, vital signs, scores and many other measurements. In R4 only `status` and `code` are mandatory, but every real implementation guide adds more:

```json
{
  "resourceType": "Observation",
  "status": "final",
  "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/observation-category", "code": "laboratory" }] }],
  "code": { "coding": [{ "system": "http://loinc.org", "code": "4548-4", "display": "Hemoglobin A1c/Hemoglobin.total in Blood" }] },
  "subject": { "reference": "Patient/demo-p001" },
  "effectiveDateTime": "2026-10-02T08:30:00+02:00",
  "valueQuantity": { "value": 6.1, "unit": "%", "system": "http://unitsofmeasure.org", "code": "%" }
}
```

The Patient resource holds demographics and identifiers. Keep two things apart: `Patient.id` is the server's local resource id, while `Patient.identifier` holds business identifiers (MRN, national ID) with a `system` URI for each assigning authority. When an HL7 v2 admission becomes FHIR, PID-3 maps to Patient.identifier and PV1-19 to Encounter.identifier, in distinct namespaces. The [ADT mapping guide](https://alabenaicha.me/insights/hl7-adt-to-fhir-patient-encounter) covers the event-specific decisions, the [HL7 message types reference](https://alabenaicha.me/insights/hl7-message-types-sample-messages) shows the source segments, and the [synthetic ADT/FHIR example](https://alabenaicha.me/resources/synthetic-adt-fhir.json) on this page gives a small file to test against.

## Bundles: searchset, transaction and batch

| Bundle type                               | Sent by | Behaviour                                                         |
| ----------------------------------------- | ------- | ----------------------------------------------------------------- |
| `searchset`                               | Server  | Search results, with paging links                                 |
| `transaction`                             | Client  | All entries succeed or none do                                    |
| `batch`                                   | Client  | Each entry processed independently, no references between entries |
| `transaction-response` / `batch-response` | Server  | One status per entry                                              |
| `history`                                 | Server  | Versions from a history interaction                               |
| `document` / `message`                    | Either  | FHIR documents and FHIR messaging                                 |
| `collection`                              | Either  | A plain set of resources                                          |

A small transaction that creates a Patient only if it does not exist yet, then creates or updates its Encounter by business identifier:

```json
{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:5b2f6c1e-0d6a-4f1e-9a51-2a1f0c0e7a01",
      "resource": {
        "resourceType": "Patient",
        "identifier": [{ "system": "https://example.org/demo-hospital/patients", "value": "DEMO-P001" }],
        "name": [{ "family": "DOE", "given": ["JANE"] }],
        "birthDate": "1990-01-01"
      },
      "request": {
        "method": "POST",
        "url": "Patient",
        "ifNoneExist": "identifier=https://example.org/demo-hospital/patients|DEMO-P001"
      }
    },
    {
      "fullUrl": "urn:uuid:9c1d7e2a-3b4f-4c5d-8e6f-7a8b9c0d1e02",
      "resource": {
        "resourceType": "Encounter",
        "identifier": [{ "system": "https://example.org/demo-hospital/visits", "value": "DEMO-V001" }],
        "status": "in-progress",
        "class": { "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", "code": "IMP" },
        "subject": { "reference": "urn:uuid:5b2f6c1e-0d6a-4f1e-9a51-2a1f0c0e7a01" },
        "period": { "start": "2026-10-05T10:00:00+01:00" }
      },
      "request": {
        "method": "PUT",
        "url": "Encounter?identifier=https://example.org/demo-hospital/visits|DEMO-V001"
      }
    }
  ]
}
```

Send it with `POST [base]` and `Content-Type: application/fhir+json`. The server processes DELETE, then POST, then PUT/PATCH, then GET entries, and rewrites `urn:uuid` references to the ids it assigns. R4 does not spell out every edge case for a reference to a conditional create that matched an existing resource, so test that path on your target server. Check the CapabilityStatement for the `transaction` and `batch` interactions before relying on either; the [Bundle resource](https://hl7.org/fhir/R4/bundle.html) page has the full rules.

## Conditional create and update for idempotent writes

Interface engines retry. Without conditional writes, a retried admission creates a second Patient. The [HTTP specification](https://hl7.org/fhir/R4/http.html) gives two tools, both still marked trial use in R4:

```bash
curl -s -X POST "$BASE/Patient" \
  -H "Content-Type: application/fhir+json" \
  -H "If-None-Exist: identifier=https://example.org/demo-hospital/patients|DEMO-P001" \
  -H "Authorization: Bearer $TOKEN" \
  -d @patient-demo-p001.json
```

| Matches | Conditional create (`If-None-Exist`) | Conditional update (`PUT [base]/Patient?identifier=...`) |
| ------- | ------------------------------------ | -------------------------------------------------------- |
| None    | Creates the resource                 | Creates the resource                                     |
| One     | Returns `200 OK`, creates nothing    | Updates the match                                        |
| Several | `412 Precondition Failed`            | `412 Precondition Failed`                                |

A 412 on several matches is a data-quality signal, usually duplicate patients. Route it to a work queue; do not retry it in a loop.

## Errors, OperationOutcome and $validate

Failed requests return an HTTP status and, usually, an OperationOutcome:

| Status    | Typical meaning                                   |
| --------- | ------------------------------------------------- |
| 400       | Unparseable or structurally invalid content       |
| 401 / 403 | Missing token, or token without the needed scope  |
| 404 / 410 | Unknown resource, or deleted resource             |
| 412       | Version conflict or ambiguous conditional match   |
| 422       | Valid FHIR that breaks a profile or business rule |

```json
{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "required",
      "diagnostics": "Observation.status: minimum required = 1, but only found 0",
      "expression": ["Observation.status"]
    }
  ]
}
```

Log `issue.code` and `issue.expression`, not just the HTTP status. Before sending, validate against the receiving profiles. Servers that support [$validate](https://hl7.org/fhir/R4/resource-operation-validate.html) accept the resource as the request body (`POST [base]/Observation/$validate`); to name a profile, send a Parameters resource instead, because the raw-body form takes no URL parameters. In CI, the HL7 FHIR Validator with the implementation guide packages catches most profile errors before a partner sees them.

## Authorization: SMART on FHIR and backend services

[FHIR is not a security protocol](https://hl7.org/fhir/R4/security.html); a valid resource grants no access. Most FHIR APIs use the [SMART App Launch](https://hl7.org/fhir/smart-app-launch/) framework (2.2.0 is the current published release). Two flows cover most integrations:

* **User-facing apps** use the authorization code flow with PKCE, launched from the EHR or standalone. The [SMART on FHIR app launch guide](https://alabenaicha.me/insights/smart-on-fhir-app-launch) walks through it.
* **System-to-system integrations** use [SMART Backend Services](https://hl7.org/fhir/smart-app-launch/backend-services.html): the OAuth client credentials grant, authenticated with a signed JWT (`private_key_jwt`) instead of a shared secret.

```bash
curl -s -X POST https://auth.example.org/oauth2/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "grant_type=client_credentials" \
  --data-urlencode "scope=system/Patient.rs system/Observation.rs" \
  --data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
  --data-urlencode "client_assertion=$SIGNED_JWT"
```

The JWT header carries `alg` (RS384 or ES384), `kid` and `typ`; its claims are `iss` and `sub` (both your `client_id`), `aud` (the token endpoint URL), `exp` (at most five minutes ahead) and a unique `jti`. Register the public key as a JWKS, ideally by URL so you can rotate keys. SMART v2 scopes such as `system/Observation.rs` replace the v1 form `system/Observation.read`; ask the server which syntax it accepts.

For Epic, distinguish patient access, clinician-facing SMART launch and backend services, and confirm the organization's supported APIs and production access requirements; the [Epic startup guide](https://alabenaicha.me/insights/epic-fhir-for-healthtech-startups) separates these paths. For Oracle Health Millennium, use the current [platform documentation](https://docs.oracle.com/en/industries/health/millennium-platform-apis/) and the target organization's endpoints. Sandbox behaviour does not establish production availability.

## Subscriptions: R4, the R5 backport and R5

There are three models, and servers differ in which they implement:

* **R4 Subscription** is criteria-based: the client registers a search expression and a channel (`rest-hook`, `websocket`, `email`, `sms` or `message`). It is trial use and support is uneven.
* **The [Subscriptions R5 Backport](https://hl7.org/fhir/uv/subscriptions-backport/) IG** (1.1.0, STU 1.1, January 2023, still the current publication) brings topic-based subscriptions to R4 and R4B. The client subscribes to a SubscriptionTopic by canonical URL. Notifications arrive as a `history` Bundle whose first entry is a Parameters resource carrying the subscription status, and the payload can be `empty`, `id-only` or `full-resource`.
* **[R5 Subscriptions](https://hl7.org/fhir/R5/subscriptions.html)** make SubscriptionTopic, Subscription and SubscriptionStatus first-class resources, with a `subscription-notification` Bundle type, handshake and heartbeat notifications.

Whichever model you get, treat notifications as hints. Keep a reconciliation search (for example by `_lastUpdated`) for missed or out-of-order events, and confirm the server's retry behaviour.

## Questionnaire and QuestionnaireResponse

[Questionnaire](https://hl7.org/fhir/R4/questionnaire.html) defines a form: items with a `linkId`, a type, answer options or value sets, and `enableWhen` conditions. [QuestionnaireResponse](https://hl7.org/fhir/R4/questionnaireresponse.html) stores the answers and points back to the form through its `questionnaire` canonical. Search them with `GET [base]/QuestionnaireResponse?subject=Patient/demo-p001&questionnaire=...`. For pre-population, extraction into Observations and adaptive forms, look at [Structured Data Capture](https://hl7.org/fhir/uv/sdc/) (STU 4, built on R4).

For population-level extracts, Bulk Data (`$export`) returns NDJSON files asynchronously instead of paging through searches.

## Integration checklist

1. Agree the implementation guide, FHIR version and identifier systems before writing the mapper.
2. Read the CapabilityStatement and `.well-known/smart-configuration`; list the interactions, search parameters and profiles you depend on.
3. Use conditional create or update keyed on business identifiers, and send `If-Match` on updates.
4. Follow paging links verbatim and handle `include` entries separately.
5. Validate against the receiving profiles in CI, and log OperationOutcome details in production.
6. Test duplicates, updates, deletes, 412s and token expiry, not only the happy path.
7. Choose the server deliberately; the [FHIR servers comparison](https://alabenaicha.me/insights/fhir-servers-compared) helps with that choice.

If you need a second pair of eyes on a FHIR API design or an HL7 v2 to FHIR interface, see the [HL7 and FHIR integration service](https://alabenaicha.me/services/hl7-fhir-integration) or [discuss your project](https://alabenaicha.me/contact?intent=project).
