Ovok blog

FHIR API and FHIR Search: A Practical Guide

A practical FHIR API guide: CapabilityStatement, CRUD, FHIR search parameters and modifiers, _include, paging, transactions and OperationOutcome.

A FHIR API is a REST API with a fixed shape: every resource type lives under one base URL, CRUD uses plain HTTP verbs, and FHIR search uses query parameters like GET /Observation?patient=123&code=http://loinc.org|8867-4&_sort=-date&_count=20. Learn the base URL, the CapabilityStatement, a dozen search rules and Bundles, and you can work with any FHIR server. This practical guide walks through each piece with concrete HTTP examples, using FHIR R4.

Key takeaways

  • Start every integration with GET [base]/metadata. The CapabilityStatement tells you which resources, interactions, search parameters and operations the server really supports.
  • Searches return a Bundle of type searchset. Follow Bundle.link with relation: next for paging instead of building page URLs yourself.
  • Search parameters have types (token, reference, date, string, quantity), and types decide which prefixes and modifiers work.
  • _include and _revinclude fetch related resources in one round trip; chaining filters by a referenced resource's fields.
  • Use transaction Bundles for all-or-nothing writes, and conditional create or update to avoid duplicates.
  • Errors come back as OperationOutcome. Read its issue array, not just the HTTP status.

New to FHIR in general? Read What is FHIR? first.

The base URL and the CapabilityStatement

Every FHIR server has a base URL. All paths are relative to it: [base]/Patient, [base]/Observation/456. For Ovok's sandbox, the FHIR R4 base is https://api.sandbox.ovok.com/fhir/R4.

The first call to make is the capabilities interaction:

  • GET [base]/metadata
  • Accept: application/fhir+json

The response is a CapabilityStatement. Look at:

  • fhirVersion — for example 4.0.1 for R4.
  • rest[0].resource[] — each supported resource type with its interaction list (read, create, update, search-type…), versioning, conditional support and searchParam list.
  • rest[0].interaction — server-wide interactions such as transaction and batch.
  • rest[0].security — the authorization scheme, often OAuth and SMART on FHIR, with authorize and token URLs.
  • operation entries — extended operations like $everything or $lastn.

Ovok documents its CapabilityStatement route in detail. According to the docs, it reports FHIR 4.0.1, lists 146 resource types, advertises transaction and batch, versioned resources, conditional create, JSON Patch, and operations including Observation/$lastn, Questionnaire/$populate and QuestionnaireResponse/$extract. Treat the live statement as the source of truth for the environment you call.

Authentication: OAuth 2.0 and SMART on FHIR

Most FHIR APIs expect a bearer token: Authorization: Bearer <token>. How you get it depends on who is calling:

  • User-facing apps use the OAuth 2.0 authorization code flow with PKCE, typically following SMART App Launch. Scopes such as patient/Observation.rs (read and search) or user/*.cruds describe what the token may do.
  • Backend services use the client_credentials grant, authenticating with a client secret or a signed JWT (private_key_jwt).

Discover endpoints rather than hard-coding them: GET [base]/.well-known/smart-configuration returns the authorize and token URLs, supported grants, scopes and PKCE methods. Ovok serves this document at https://api.sandbox.ovok.com/fhir/R4/.well-known/smart-configuration and, per its docs, supports client_credentials, authorization_code and refresh_token grants with S256 PKCE. A server-side token request looks like:

  • POST https://api.sandbox.ovok.com/oauth2/token
  • Content-Type: application/x-www-form-urlencoded
  • HTTP Basic auth with the client id and secret, body grant_type=client_credentials

Never ship a client secret in a browser or mobile app. Patient-facing apps should use the Ovok authentication flows and SDKs.

A token only gets you in the door. What a caller may read or write is decided by authorization rules; in Ovok those are Access Policies, and searches run with the caller's token, so results only include resources the policy allows. Note the docs' warning: a project membership without any policy is not restricted by one, so assign least-privilege policies deliberately.

CRUD interactions

The core RESTful interactions:

  • Create: POST [base]/Patient with the resource as the body. The server assigns the id and returns 201 Created with a Location header such as [base]/Patient/123/_history/1.
  • Read: GET [base]/Patient/123. Returns 404 if it never existed and 410 Gone if it was deleted.
  • Update: PUT [base]/Patient/123 with the full resource, including "id": "123". Update replaces the whole resource; elements you leave out are removed.
  • Patch: PATCH [base]/Patient/123 with Content-Type: application/json-patch+json and a body like [{"op": "replace", "path": "/active", "value": false}]. Good for changing one field without a read-modify-write of the whole resource.
  • Delete: DELETE [base]/Patient/123.

Useful headers:

  • Prefer: return=representation asks the server to return the saved resource; return=minimal returns only headers.
  • If-Match: W/"3" makes an update conditional on the current version (optimistic locking). If someone else changed the resource, you get 412 Precondition Failed instead of silently overwriting their work.

FHIR search basics

A search is GET [base]/[type]?param=value&param=value. Parameters joined with & are AND; comma-separated values inside one parameter are OR. For long queries, POST [base]/[type]/_search with a form-encoded body is equivalent and keeps PHI out of URLs and access logs.

Examples:

  • GET [base]/Patient?family=Mustermann&birthdate=1970-01-01
  • GET [base]/Observation?patient=Patient/123&code=http://loinc.org|8867-4,http://loinc.org|8480-6
  • GET [base]/Encounter?status=in-progress,arrived

The result is a Bundle with type: searchset. Each entry has a fullUrl, a resource and search.mode (match for hits, include for included resources). total may be present, but servers may estimate it or omit it.

Search parameter types

Each search parameter has a type, listed on the resource page in the specification and in the CapabilityStatement:

  • token — codes and identifiers. system|code, code alone, or system| for any code in a system: identifier=https://clinic.example.de/patient-id|PAT-001.
  • reference — links: subject=Patient/123 or patient=123.
  • date — dates and times, with prefixes: date=ge2026-10-01&date=lt2026-11-01.
  • string — text, case- and accent-insensitive prefix match by default: name=muster matches "Mustermann".
  • quantity — numbers with units: value-quantity=gt100|http://unitsofmeasure.org|mg/dL.
  • number, uri, composite and special cover the rest.

Comparison prefixes for date, number and quantity: eq, ne, gt, lt, ge, le, sa (starts after), eb (ends before) and ap (approximately).

Modifiers

Modifiers follow a colon after the parameter name:

  • name:exact=Mustermann — exact, case-sensitive string match.
  • name:contains=erma — substring match.
  • birthdate:missing=true — resources where the element is absent.
  • status:not=cancelled — token negation.
  • code:text=glucose — match the text or display of a coded element.
  • code:below=... and code:above=... — subsumption on hierarchical code systems, where the server supports it.
  • subject:Patient=123 — restrict a polymorphic reference to one target type.

The full rules live in the FHIR R4 search specification. Not every server supports every modifier, so check the CapabilityStatement and test.

Common result parameters

  • _count=50 — page size.
  • _sort=-date — sort descending by date; combine with commas, _sort=status,-date.
  • _summary=true or _elements=id,code,valueQuantity — return less data per resource.
  • _total=accurate — ask for an exact total, which can be expensive on big data sets.
  • _lastUpdated=gt2026-10-01 and _id=123,456 — work on every resource type.

Chaining, _include and _revinclude

Chaining filters by a field of a referenced resource. Find a patient's observations by the patient's identifier without first looking up the id:

  • GET [base]/Observation?subject:Patient.identifier=https://clinic.example.de/patient-id|PAT-001

Reverse chaining with _has finds resources referenced by others. Patients with at least one heart-rate observation:

  • GET [base]/Patient?_has:Observation:subject:code=http://loinc.org|8867-4

_include pulls referenced resources into the same Bundle:

  • GET [base]/Observation?code=http://loinc.org|8867-4&_include=Observation:subject

_revinclude pulls resources that point at the matches:

  • GET [base]/Patient?_id=123&_revinclude=Observation:subject

These save round trips, but large _revinclude results can be heavy. Page them and keep the filters tight. Support varies by server, so confirm with the CapabilityStatement.

Paging and limits

Never assume one response holds every match. A searchset Bundle includes link entries with relations self, next and sometimes previous, first and last. To read all pages, follow next until it is gone, using the URL exactly as returned. Some servers also accept _offset, but next links are the portable method.

Servers set their own limits, and you should look them up. Ovok's docs, for example, state for the document search route that the page size defaults to 100, that the FHIR server reduces a _count above 1,000 to 1,000, and that an _offset above 10,000 is rejected. For long lists, sort in the other direction or narrow the filter (for example by _lastUpdated) instead of paging deep.

For a whole patient record, the $everything operation is often simpler than many searches. Ovok documents Patient `$everything` with _since, _type, _count and _offset filters. For "latest value per code", $lastn returns the most recent Observations; see Get last observations.

Bundles: batch and transaction

To send several operations in one request, POST [base] with a Bundle:

  • batch — each entry is processed independently; some may succeed while others fail. The response is a batch-response Bundle with one response.status per entry.
  • transaction — all entries succeed or none do. Entries can reference each other through temporary urn:uuid: ids in fullUrl, which the server replaces with real ids.

A typical transaction creates a Patient and an Observation that references it: entry one has fullUrl urn:uuid:5b1f..., the Patient resource and request: {"method": "POST", "url": "Patient"}; entry two has the Observation with subject.reference set to the same urn:uuid and request: {"method": "POST", "url": "Observation"}.

Ovok documents its behaviour precisely in the transaction-bundles feature page:

  • The feature is on for new projects and makes transactions atomic. If one entry fails, the whole request is rejected with an OperationOutcome and nothing is written.
  • With the feature off, a transaction Bundle silently runs as a batch and still returns 200, so check each entry's response.status if you are not sure.
  • Limits when on: more than 50 update (PUT) entries, or more than 8 entries in a Bundle that contains a conditional create, update or delete, is rejected with 400.
  • A rolled-back transaction can return 412 for a stale If-Match, 409 for a serialisation conflict, or 400 for a detected deadlock. Retry 409 and deadlocks; re-read before retrying 412.

Keep each transaction to one logical change and split large imports into several transactions.

Conditional create and update

Conditional operations prevent duplicates when the same data may arrive twice, a common situation for device readings and integrations.

  • Conditional create: POST [base]/Patient with header If-None-Exist: identifier=https://clinic.example.de/patient-id|PAT-001. No match: the resource is created. One match: the server returns 200 OK and creates nothing. Several matches: 412.
  • Conditional update: PUT [base]/Patient?identifier=https://clinic.example.de/patient-id|PAT-001 with the resource as the body. No match creates it, one match updates it, several matches fail with 412.
  • Conditional delete: DELETE [base]/Observation?identifier=....

Inside a transaction, conditional create uses request.ifNoneExist on the entry. Always match on a business identifier with a stable system, never on names or dates.

Versioning and _history

FHIR servers that version resources keep every revision. meta.versionId increases with each update and the ETag header carries it as W/"3".

  • GET [base]/Patient/123/_history — all versions of one resource.
  • GET [base]/Patient/123/_history/2 — a specific version (vread).
  • GET [base]/Patient/_history?_since=2026-10-01T00:00:00Z — changes to a type since a point in time, where supported.

History is handy for audits and sync jobs. For live updates, use Subscriptions instead of polling; we cover that in real-time FHIR Subscriptions over WebSockets.

Errors and OperationOutcome

FHIR uses normal HTTP status codes and adds an OperationOutcome body explaining what went wrong:

  • 400 Bad Request — invalid search parameter, malformed resource.
  • 401 Unauthorized — missing or expired token.
  • 403 Forbidden — the token is valid but the policy does not allow the operation.
  • 404 Not Found and 410 Gone — unknown or deleted resource.
  • 409 Conflict and 412 Precondition Failed — version conflicts and failed conditions.
  • 422 Unprocessable Entity — the resource breaks a business or profile rule.
  • 429 Too Many Requests — rate limited; back off and retry.

Each issue has a severity (fatal, error, warning, information), a code (such as invalid, not-found, forbidden) and often a diagnostics string and an expression pointing at the offending element. Log the issue array, without patient data, and surface the diagnostics to developers.

GraphQL as an alternative read path

Some FHIR servers also offer GraphQL, which lets a client select exactly the fields a screen needs. Ovok exposes FHIR R4 resources at https://api.sandbox.ovok.com/fhir/R4/$graphql. Each type has a read query (Patient(id:)), a list query (PatientList(...)) and a connection query (PatientConnection(...)) that returns cursors for paging, plus create, update, patch and delete mutations. The same Access Policies apply. See GraphQL queries and pagination.

A checklist for production FHIR clients

  1. Read the CapabilityStatement at startup or build time and fail loudly if a parameter you rely on is missing.
  2. Use POST _search for queries that contain personal data.
  3. Follow next links; never assume one page is complete.
  4. Use conditional create or update keyed on identifiers for anything that may be retried.
  5. Use transactions for multi-resource writes, and know your server's limits.
  6. Send If-Match on updates edited by humans.
  7. Parse OperationOutcome and handle 429 with backoff.
  8. Scope tokens and access policies to least privilege.

FAQ

What is the difference between a FHIR API and a regular REST API?

A FHIR API is a REST API whose resources, URLs, search syntax and error format are standardised. A client written for one FHIR server works largely unchanged against another, subject to version and profile differences.

How do I search FHIR resources by date range?

Repeat the date parameter with prefixes: GET [base]/Observation?date=ge2026-10-01&date=lt2026-11-01. The two conditions are combined with AND.

It adds the resources referenced by your matches to the same Bundle, so you can fetch observations and their patients in one request. _revinclude does the reverse.

Why does my FHIR search return only some results?

Results are paged. Follow the next link in the Bundle. The server may also cap _count, and Access Policies may hide resources you are not allowed to see.

Should I use a batch or a transaction Bundle?

Use a transaction when the writes belong together and partial success would leave inconsistent data. Use a batch for independent operations where partial success is acceptable.

How do I avoid duplicate resources when retrying?

Use conditional create (If-None-Exist) or conditional update keyed on a business identifier. A retry then finds the existing resource instead of creating a second one.

The specification defines _text and _content and the :text modifier, but support varies widely. Check the CapabilityStatement before relying on them.

Conclusion

The FHIR API is predictable once you know its pattern: discover with metadata, authenticate with OAuth, use typed search parameters, follow paging links, write atomically with transactions and read OperationOutcome when things fail. The fastest way to make it stick is to run these requests yourself. Create a free project in the Ovok Developer sandbox, keep the Ovok docs open, and when you are planning production, see pricing, the Ovok offer for startups and how Ovok supports remote patient monitoring. Comparing backends? Read our guide to the best digital health backend for European startups.

Keep reading