Healthcare IntegrationUpdated -11 min read

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.

Ala Ben Aicha

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

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

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. 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) 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 explains the R4/R4B/R5 relationships.

Discover the server: CapabilityStatement and SMART configuration

Every conformant server publishes a CapabilityStatement. Read it before you design the client.

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:

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:

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:

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.

The response is a searchset Bundle:

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

{
  "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 covers the event-specific decisions, the HL7 message types reference shows the source segments, and the synthetic ADT/FHIR example 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:

{
  "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 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 gives two tools, both still marked trial use in R4:

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
{
  "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 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; a valid resource grants no access. Most FHIR APIs use the 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 walks through it.
  • System-to-system integrations use SMART Backend Services: the OAuth client credentials grant, authenticated with a signed JWT (private_key_jwt) instead of a shared secret.
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 separates these paths. For Oracle Health Millennium, use the current platform documentation 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 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 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 defines a form: items with a linkId, a type, answer options or value sets, and enableWhen conditions. QuestionnaireResponse 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 (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 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 or discuss your project.

FHIRFHIR APIHL7REST APISMART on FHIRIntegrationInteroperability

Related reading and services

Let's Continue the Conversation

Have questions about this topic? I'd love to hear from you.