---
title: "FHIR R4"
description: "Local, stateful API emulators for healthcare and life science development. FHIR, SMART on FHIR, EHRs, payers, and life science APIs, without PHI. Synthetic data only."
canonical_url: "https://www.emuhealth.dev/docs/fhir"
---
# FHIR R4

A stateful FHIR R4 (4.0.1) server for developing and testing health apps. It runs on `http://localhost:4100` by default and accepts any R4 resource type. It can serve [R4B or R5](#fhir-versions) instead. Load synthetic data only, never real patient data.

```bash
npx emuhealth --service fhir
```

The server starts with 5 generated synthetic patients, each with encounters, vital signs, conditions, medications, labs, allergies, and immunizations, so you can read and search right away (`GET /Patient`).

## Resources

The FHIR base URL is the server root. All responses use `application/fhir+json`.

- `POST /:type` creates a resource with a server-assigned id (any id in the body is ignored)
- `GET /:type/:id` reads the current version
- `PUT /:type/:id` updates a resource, or creates it with that id when it does not exist (the body `id` must match the URL)
- `PATCH /:type/:id` applies a JSON Patch (`Content-Type: application/json-patch+json`)
- `DELETE /:type/:id` deletes a resource logically; later reads return `410 Gone`

Requests without a token have full access by default. Requests that present a SMART token are limited to its scopes and patient (see [SMART App Launch](#smart-app-launch)).

## Search

`GET /:type?<params>` and `POST /:type/_search` (form-encoded parameters) return a `searchset` Bundle with `total` and `self`, `next`, and `previous` links.

- Every type supports `_id` and `_lastUpdated`. Patient, Practitioner, PractitionerRole, Organization, Location, Provenance, Observation, Condition, Encounter, MedicationRequest, AllergyIntolerance, Immunization, Procedure, DiagnosticReport, DocumentReference, CarePlan, CareTeam, Goal, ServiceRequest, Coverage, ExplanationOfBenefit, Device, and Medication support their common parameters, such as `patient`, `subject`, `code`, `category`, `status`, `date`, `identifier`, `name`, `family`, `given`, `gender`, and `birthdate`.
- **string**: accent- and case-insensitive prefix match; `:exact` and `:contains` are supported.
- **token**: `code`, `system|code`, `|code`, or `system|`; `:not` and `:text` are supported.
- **date**: partial dates cover their whole range (`2026`, `2026-02`); prefixes `eq`, `ne`, `lt`, `gt`, `le`, `ge`, `sa`, `eb`, and `ap`. Periods are matched as ranges.
- **reference**: `id`, `Type/id`, an absolute URL, or `:Type` (for example `subject:Patient=123`). `patient=123` means `Patient/123`.
- Any parameter accepts `:missing=true|false`. Comma separated values match any of them; repeating a parameter requires all of them.
- `_sort` takes a comma separated list with `-` for descending. `_count` sets the page size (default 50, max 500). `_summary=count` returns only `total`.
- Unknown parameters are ignored and left out of the `self` link. Send `Prefer: handling=strict` to get a `400` instead.

```bash
curl 'http://localhost:4100/Observation?patient=synthetic-patient-1&code=http://loinc.org|8867-4&date=ge2026-01-01&_sort=-date'
```

### Includes and chaining

- `_include=Observation:patient` adds the referenced resources to the page as `search.mode: include` entries. Add a target type to narrow it, for example `_include=Observation:subject:Patient`.
- `_revinclude=Observation:subject` adds resources that reference the matches.
- `_include:iterate` and `_revinclude:iterate` (or a trailing `:iterate`) also apply to resources added by other includes, up to five rounds.
- Includes apply to the current page only and do not count toward `total`.
- Chained parameters filter through a reference using the target type's parameters: `Observation?patient.family=example`, `Observation?subject:Patient.name=example`, or nested chains such as `Observation?patient.organization.name=clinic` (up to four levels).

```bash
curl 'http://localhost:4100/Patient?_id=synthetic-patient-1&_revinclude=Observation:subject'
```

## Conditional interactions

Each interaction matches with a search that respects the request's SMART access. More than one match returns `412` with issue code `multiple-matches`. Criteria the server does not support return `400` instead of being ignored, since an ignored criterion would match every resource. Every type that has identifiers supports `identifier`.

- **Create**: `POST /:type` with an `If-None-Exist: identifier=system|value` header creates the resource only when nothing matches. When one resource matches, it is returned with `200` and nothing is created.
- **Update**: `PUT /:type?<criteria>` updates the one match. With no match, it creates the resource, keeping the `id` from the body if there is one. An `id` that differs from the match returns `400`.
- **Delete**: `DELETE /:type?<criteria>` deletes the one match. No match is a successful no-op (`204`). Criteria are required.
- **Read**: `If-None-Match` with the current ETag, or `If-Modified-Since` at or after the last update, returns `304`.

```bash
curl -X PUT 'http://localhost:4100/Patient?identifier=urn:example:mrn|12345' \
  -H 'Content-Type: application/fhir+json' \
  -d '{"resourceType":"Patient","identifier":[{"system":"urn:example:mrn","value":"12345"}],"name":[{"family":"Example"}]}'
```

The CapabilityStatement reports `conditionalCreate`, `conditionalUpdate`, `conditionalRead: full-support`, and `conditionalDelete: single`.

## Batch and transaction

`POST /` (the base URL) with a `batch` or `transaction` Bundle. Each entry has a `request` with `method` and `url`, relative (`Patient/123`) or absolute on this server.

- Entries can create (`POST`), read, vread, search, or read history (`GET`), update (`PUT`), patch (`PATCH` with a `Binary` resource whose `contentType` is `application/json-patch+json` and whose `data` is the base64 encoded patch), and delete (`DELETE`). `request.ifMatch` and `request.ifNoneMatch` work as their headers do.
- **batch**: entries run independently in order. The `batch-response` has a status for every entry, and failed entries carry an `OperationOutcome` in `response.outcome`.
- **transaction**: all or nothing. Entries run in FHIR order (deletes, creates, updates and patches, then reads) and the `transaction-response` lists results in request order. If any entry fails, every change is rolled back and the response is that entry's `OperationOutcome` with its status.
- In a transaction, references to an entry's `fullUrl` (such as `urn:uuid:...`) are rewritten to the new `Type/id`. `request.ifNoneExist` makes a create conditional: one match reuses it (`200`), more than one fails with `412`.
- Two entries that change the same resource are rejected with `400`. Entries can be conditional updates and deletes (`PUT Patient?identifier=...`, `DELETE Patient?identifier=...`), as described below. In a transaction, a conditional update resolves its match before any entry runs, so other entries can reference its `fullUrl`.

```json
{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:5f1c4d8e-0000-4000-8000-000000000001",
      "resource": { "resourceType": "Patient", "name": [{ "family": "Example" }] },
      "request": { "method": "POST", "url": "Patient" }
    },
    {
      "resource": {
        "resourceType": "Observation",
        "status": "final",
        "code": { "text": "Heart rate" },
        "subject": { "reference": "urn:uuid:5f1c4d8e-0000-4000-8000-000000000001" }
      },
      "request": { "method": "POST", "url": "Observation" }
    }
  ]
}
```

## Patient $everything

`GET /Patient/:id/$everything` returns a `searchset` Bundle with the patient, every resource that references it (Encounters, Observations, Conditions, MedicationRequests, AllergyIntolerances, Immunizations, and so on), and the Practitioners, Organizations, and other resources those point to. Other patients are never included, even when a record mentions them. `GET /Patient/$everything` does the same for every patient.

- `_type=Observation,Condition` limits the resource types, `_since` limits to resources updated at or after an instant, and `_count` pages the result with `next` and `previous` links.
- An unknown patient returns `404` and a deleted one `410`.

```bash
curl 'http://localhost:4100/Patient/syn-patient-001/$everything?_type=Patient,Condition,MedicationRequest'
```

## Bulk Data export

FHIR Bulk Data Access (`$export`) at three levels, with NDJSON output:

| Kick-off | Exports |
|----------|---------|
| `GET /$export` | Every resource |
| `GET /Patient/$export` | Resources in any patient's compartment |
| `GET /Group/:id/$export` | Resources in the compartments of the Group's patient members |

1. Send the kick-off with `Prefer: respond-async`. The response is `202 Accepted` with a `Content-Location` status URL.
2. `GET` the status URL for the manifest: `transactionTime`, `request`, `requiresAccessToken`, and an `output` list with one NDJSON file URL and `count` per resource type. The emulator finishes exports right away, so the first poll returns `200`.
3. Download each file (`application/fhir+ndjson`). `DELETE` the status URL to remove the export.

Parameters: `_type` (comma separated types), `_since`, and `_outputFormat` (`application/fhir+ndjson`, `application/ndjson`, or `ndjson`). `_typeFilter` and `_elements` return `400`.

With a SMART token, the export only includes what the token can read: `system/*.rs` from [Backend Services](#smart-app-launch) exports everything, a narrower scope exports only its types (`_type` outside it gets `403`), and a patient token exports only its patient. Exports started with a token can only be read with a token from the same client (`requiresAccessToken: true`).

```bash
curl -i 'http://localhost:4100/$export?_type=Patient,Observation' -H 'Prefer: respond-async'
```

## SMART App Launch

The server is also a SMART on FHIR authorization server, so SMART apps can launch against it locally. Discovery is at `GET /.well-known/smart-configuration`.

| Endpoint | Purpose |
|----------|---------|
| `GET` or `POST /auth/authorize` | Authorization code flow with patient and practitioner pickers |
| `POST /auth/token` | `authorization_code` and `refresh_token` grants |
| `POST /auth/introspect`, `POST /auth/revoke` | Token introspection and revocation |
| `GET /auth/launch` | EHR launch simulator |
| `GET /.well-known/jwks.json` | Key for verifying `id_token` signatures |
| `GET /.well-known/openid-configuration` | OpenID Connect discovery for the `id_token` |

- **Standalone launch:** `launch/patient` in the scope shows a patient picker; `openid fhirUser` or any `user/` scope shows a practitioner picker first. The token response carries `patient`, and `openid fhirUser` adds a signed `id_token` with the `fhirUser` claim. A token can always read its own `fhirUser` resource, whatever its scopes.
- **EHR launch:** open `/auth/launch?launch_uri=<your app launch URL>` and pick a patient (or pass `patient`, `encounter`, and `practitioner` ids). The app receives `iss` and `launch`, and sending `launch` with the `launch` scope to `/auth/authorize` skips the pickers; the token carries `patient` and `encounter`.
- **PKCE:** `S256` is required for public clients (`plain` is rejected). Set `fhir.smart.requirePkce: false` to allow public clients without it.
- **Tokens:** access tokens last one hour. `offline_access` or `online_access` adds a refresh token; a refresh can narrow but not widen scopes. Tokens are cleared on reset.
- **Enforcement:** a request that presents a SMART access token is held to it. Scopes grant interactions per resource type (`patient/Observation.rs` in v2, `patient/Observation.read` in v1, `*` for all types); interactions outside them get `403`. `patient/` scopes also limit reads, searches, history, `$everything`, includes, and Bundle entries to the token's patient: other patients' records look like `404`, and writes outside the patient get `403`. `user/` scopes are not limited to a patient. v2 granular scopes such as `patient/Observation.rs?category=laboratory` filter reads and searches. Unknown or expired tokens get `401` with `WWW-Authenticate: Bearer`.
- **Strict mode:** requests without a token have full access by default, so quick start and `curl` keep working. Set `fhir.smart.required: true` to require a token on every FHIR request; `/metadata` and `/.well-known/smart-configuration` stay public. `/metadata` advertises the OAuth endpoints in `rest.security`.
- **Backend Services:** server-to-server clients register public keys (`jwks` inline or `jwks_uri`) and call `/auth/token` with `grant_type=client_credentials`, `system/` scopes, and a `client_assertion` JWT signed with RS384 or ES384 (`iss` and `sub` the client id, `aud` the token endpoint, `exp` at most 5 minutes ahead, a unique `jti`). Tokens last 5 minutes, carry only `system/` scopes, and are not limited to a patient. Clients registered with keys can also use `private_key_jwt` for the authorization code flow.
- **Clients:** any `client_id` and `redirect_uri` is accepted until you register clients. Registered clients are checked, confidential clients must send their secret (Basic auth or `client_secret`), and an optional `scope` limits what a client can be granted:

```yaml
fhir:
  smart:
    clients:
      - client_id: my-smart-app
        redirect_uris: ["http://localhost:3000/callback"]
        scope: "launch/patient openid fhirUser patient/*.rs offline_access"
      - client_id: my-confidential-app
        client_secret: dev-secret
        redirect_uris: ["http://localhost:3001/callback"]
      - client_id: my-backend-service
        scope: "system/*.rs"
        jwks:
          keys:
            - { kty: RSA, kid: key-1, alg: RS384, use: sig, n: "...", e: AQAB }
```

## Conformance

Every change to the FHIR server runs the ONC Inferno test kits in CI: the US Core 3.1.1 FHIR API tests against the five default patients, and the SMART App Launch STU2 standalone launch, EHR launch, and backend services tests. Only the TLS checks are expected to fail, because the emulator serves plain HTTP.

## Versioning

Every write increments `meta.versionId` and sets `meta.lastUpdated`.

- Responses include `ETag: W/"<versionId>"` and `Last-Modified`. Creates also return `Location: <base>/<type>/<id>/_history/<versionId>`.
- `If-Match: W/"<versionId>"` on `PUT`, `PATCH`, or `DELETE` returns `412 Precondition Failed` when the resource has moved on.
- `If-None-Match: W/"<versionId>"` on a read returns `304 Not Modified` when nothing changed.
- `Prefer: return=minimal` returns an empty body; `Prefer: return=OperationOutcome` returns an outcome instead of the resource.

```bash
curl -X PUT http://localhost:4100/Patient/synthetic-patient-1 \
  -H 'Content-Type: application/fhir+json' \
  -H 'If-Match: W/"1"' \
  -d '{"resourceType":"Patient","id":"synthetic-patient-1","gender":"female"}'
```

## History

- `GET /:type/:id/_history/:vid` reads a specific version
- `GET /:type/:id/_history`, `GET /:type/_history`, and `GET /_history` return a `history` Bundle, newest first

History supports `_count` and `_since`. Deleted versions appear as entries with `request.method` `DELETE` and no resource.

## Inspector

Open `http://localhost:4100/_inspector` in a browser to see what is in the server:

- **Resources:** every resource type with its count, the resources of a type, and each resource's current JSON with all of its versions
- **Recent changes:** the latest 100 creates, updates, patches, and deletes
- **SMART:** registered clients and live tokens (shortened), and whether tokens are required
- **Exports:** Bulk Data jobs and links to their manifests

The inspector does not need a token, even when `fhir.smart.required` is on. It only shows synthetic data you loaded or generated.

## Capability statement

`GET /metadata` returns a `CapabilityStatement` (FHIR 4.0.1, JSON) generated from what the server implements: every R4 resource type with its interactions, versioning, supported search parameters, `searchInclude` and `searchRevInclude` values, plus the `batch`, `transaction`, and system history interactions. FHIR clients that check `/metadata` on connect work without extra setup.

## FHIR versions

The server speaks one FHIR version at a time: R4 (4.0.1) by default, or R4B (4.3.0) or R5 (5.0.0) with `fhir.fhirVersion`.

```yaml
fhir:
  fhirVersion: R5 # or 4.0.1, R4, 4.3.0, R4B, 5.0.0
```

- **Resource types.** The resource types are the ones that version defines. For example, R5 has `DeviceAssociation` and `SubscriptionTopic` but no `DeviceUseStatement`, and R4B has `SubscriptionTopic` but none of the R4 `MedicinalProduct*` types. Other types return `404`.
- **CapabilityStatement and `$versions`.** The CapabilityStatement reports the version. `GET /$versions` returns it as `Parameters`, or as `{ "versions": [...], "default": ... }` when you ask for `application/json`.
- **Media types.** On R4B and R5, responses use `application/fhir+json; fhirVersion=4.3` or `fhirVersion=5.0`. On every version, an `Accept` header that asks only for another `fhirVersion` gets `406`, and a body sent with another `fhirVersion` gets `415`.
- **Generated data.** The synthetic population is generated in the version's shape. In R5, examples:
  - Encounters use `class` as a list, `actualPeriod`, `reason`, and `participant.actor`;
  - MedicationRequests use `medication.concept`;
  - Procedures use `occurrenceDateTime`;
  - DocumentReferences use `context` references and `content.profile`;
  - implanted Devices are linked to the patient by a `DeviceAssociation`, and their UDI carriers name a GS1 `issuer`;
  - CareTeam participants have a single `role`, and AllergyIntolerance reactions list `manifestation.concept`;
  - Organization, PractitionerRole, and Location keep their phone numbers in `contact`, and Organization its addresses too.

  R4B keeps the R4 shapes, which these resources share. On R5, creates and updates in those R4 shapes are converted the same way, and R4 elements R5 removed (`Device.patient`, `Device.distinctIdentifier`, `Encounter.statusHistory`, `Encounter.classHistory`) return `400`. In CI, the served data for each version passes the HL7 FHIR Validator.
- **Searches.** Search parameters cover the renamed R5 elements, so `Encounter?date=`, `MedicationRequest?code=`, and `Organization?address=` work on every version.

Resources you load or write are stored as sent. They are not converted or validated against the version. The Inferno gate in [Conformance](#conformance) runs against R4.

## Errors

Errors are `OperationOutcome` resources with a matching status: `400` for invalid input, `404` for unknown ids, resource types, or paths that are not FHIR interactions, `410` for deleted resources, `412` for version conflicts, `415` for unsupported content types, and `422` when a JSON Patch `test` fails.

## Synthetic patients

A built-in generator creates a deterministic synthetic population: the same options always produce the same records, with readable ids like `Patient/syn-patient-001`. It covers every US Core 3.1.1 profile:

- Patients with race, ethnicity, birth sex, language, and previous names and addresses. Every fifth patient, starting with the fourth, is a two-year-old, so the pediatric growth profiles have data.
- Encounters with vital sign Observations (heart rate, respiratory rate, temperature, pulse oximetry, blood pressure, height, weight, and pediatric percentiles), smoking status, Conditions with matching MedicationRequests, lab Observations and DiagnosticReports (HbA1c, cholesterol), and sometimes an AllergyIntolerance.
- Immunizations, a Procedure, a Goal, a CarePlan, a CareTeam, note DiagnosticReports, and DocumentReferences (one inline, one pointing at a `Binary`). Some patients also have an implanted Device or a declined vaccine.
- A Provenance per patient that targets the whole record, so `_revinclude=Provenance:target` works.
- One Organization, Location, and Endpoint, and two Practitioners with PractitionerRoles, are shared.

Everything generated is tagged `urn:emuhealth:tag|synthetic`, uses `urn:emuhealth:synthetic:*` identifiers and fictional 555-01xx phone numbers, and codes from ICD-10-CM, ICD-10-PCS, RxNorm, CVX, CDC Race and Ethnicity, and a small set of LOINC codes. NPIs have a valid check digit but start with 0, which no real NPI does. Where US Core would use SNOMED CT or CPT, which emuhealth does not bundle, concepts carry the meaning in `text` with the data absent reason `unknown` code.

```yaml
fhir:
  generate:
    patients: 25 # 0 to 1000, default 5
    seed: 42 # default 1
    referenceDate: "2026-01-01" # ages and dates are relative to this, default 2026-01-01
```

Set `generate: false` to start without generated patients, and add `terminology: false` for a completely empty server. Resetting the emulator restores the configured population.

## Terminology

The server starts with seven small CodeSystem fixtures (`content: fragment`) holding exactly the codes the generator uses: ICD-10-CM conditions, ICD-10-PCS procedures, RxNorm medications and drug allergens, CVX vaccines, CDC race and ethnicity categories, LOINC vital signs, labs, and note types, and UCUM units. They are not the full code systems. Each is tagged `urn:emuhealth:tag|terminology-fixture`, and LOINC and RxNorm attribution is in `THIRD_PARTY_NOTICES.md`.

- `GET /CodeSystem/$lookup?system=<url>&code=<code>` returns a `Parameters` resource with the display.
- `GET /CodeSystem/$validate-code?url=<url>&code=<code>[&display=<text>]` returns `result`, the display, and a `message` when the code is unknown or the display does not match.
- Both work on any CodeSystem in the store, so you can load your own (including nested concepts) under `fhir.resources`. Search CodeSystems by `url`, `name`, `status`, or `version`.
- Set `fhir.terminology: false` to leave the fixtures out.

```bash
curl 'http://localhost:4100/CodeSystem/$validate-code?url=http://loinc.org&code=8867-4'
```

## Seed config

Load synthetic resources at startup, on top of the generated population. Resources with an `id` keep it; others get a server-assigned id.

```yaml
fhir:
  resources:
    - resourceType: Patient
      id: synthetic-patient-1
      name:
        - family: Example
          given: [Jamie]
      gender: female
      birthDate: "1980-01-01"
    - resourceType: Observation
      status: final
      code:
        text: Heart rate
      subject:
        reference: Patient/synthetic-patient-1
```

### Importing Synthea and other files

`fhir.import` loads files or directories at startup, after the generated population (set `generate: false` to load only your files):

```yaml
fhir:
  generate: false
  import:
    - ./synthea/output/fhir # a directory of .json and .ndjson files
    - ./fixtures/extra-patient.json
```

- Transaction and batch Bundles run as they would over the API, so `urn:uuid` fullUrls, `ifNoneExist`, and conditional references such as `Practitioner?identifier=http://hl7.org/fhir/sid/us-npi|...` resolve. A conditional reference must match exactly one resource.
- In a directory, Synthea's `hospitalInformation*` and `practitionerInformation*` files load before the patient Bundles that reference them.
- Other Bundles load their entries, a `.json` file with one resource loads it, and `.ndjson` files (Bulk Data output) load one resource per line.
- Relative paths resolve from the working directory. A failure names the file and stops startup.

[Synthea](https://github.com/synthetichealth/synthea) generates synthetic patients, so its output fits the synthetic data rule. Never import real patient data.

## Subscriptions

R5 and R4B servers deliver topic-based Subscriptions over `rest-hook`. Create a SubscriptionTopic, then a Subscription for it:

```json
{
  "resourceType": "SubscriptionTopic",
  "url": "http://example.org/SubscriptionTopic/encounter-in-progress",
  "status": "active",
  "resourceTrigger": [
    {
      "resource": "http://hl7.org/fhir/StructureDefinition/Encounter",
      "supportedInteraction": ["create", "update"],
      "queryCriteria": { "current": "status=in-progress" }
    }
  ]
}
```

```json
{
  "resourceType": "Subscription",
  "status": "requested",
  "topic": "http://example.org/SubscriptionTopic/encounter-in-progress",
  "channelType": { "system": "http://terminology.hl7.org/CodeSystem/subscription-channel-type", "code": "rest-hook" },
  "endpoint": "http://localhost:3000/fhir-hook",
  "content": "full-resource",
  "parameter": [{ "name": "Authorization", "value": "Bearer my-hook-token" }],
  "filterBy": [{ "resourceType": "Encounter", "filterParameter": "patient", "value": "Patient/syn-patient-001" }]
}
```

- The server sends a `handshake` to the endpoint. A `2xx` answer makes the Subscription `active`; anything else sets `error`.
- Writes through the API that match the topic trigger (type, interaction, and `queryCriteria.current`) and the `filterBy` filters are POSTed as a `subscription-notification` Bundle: a SubscriptionStatus with the event number and focus, then the focus resource (`full-resource`), its URL only (`id-only`), or nothing (`empty`). `parameter` entries become request headers.
- A transaction's notifications go out only after it commits. Seed data and `fhir.import` do not notify.
- `GET /Subscription/{id}/$status` returns the SubscriptionStatus with the event count.
- On R4B, use the [Subscriptions R5 Backport](https://hl7.org/fhir/uv/subscriptions-backport/) shape: `criteria` holds the topic URL, `backport-filter-criteria` extensions on `_criteria` hold filters such as `Encounter?patient=Patient/123`, and `channel` holds `type: rest-hook`, `endpoint`, `header`, and the `backport-payload-content` extension. Event numbers are strings there.
- Each notification is tried once, with a 10 second timeout. Heartbeats, retries, and websocket or email channels are not emulated. R4 servers do not deliver notifications.

## Programmatic use

```typescript
import { createEmulator } from "emuhealth";

const fhir = await createEmulator({ service: "fhir" });
// fhir.url === "http://localhost:4100"
afterEach(() => fhir.reset());
afterAll(() => fhir.close());
```
