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. FollowBundle.linkwithrelation: nextfor paging instead of building page URLs yourself. - Search parameters have types (token, reference, date, string, quantity), and types decide which prefixes and modifiers work.
_includeand_revincludefetch 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
issuearray, 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]/metadataAccept: application/fhir+json
The response is a CapabilityStatement. Look at:
fhirVersion— for example4.0.1for R4.rest[0].resource[]— each supported resource type with itsinteractionlist (read, create, update, search-type…),versioning, conditional support andsearchParamlist.rest[0].interaction— server-wide interactions such astransactionandbatch.rest[0].security— the authorization scheme, often OAuth and SMART on FHIR, with authorize and token URLs.operationentries — extended operations like$everythingor$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) oruser/*.crudsdescribe what the token may do. - Backend services use the
client_credentialsgrant, 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/tokenContent-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]/Patientwith the resource as the body. The server assigns theidand returns201 Createdwith aLocationheader such as[base]/Patient/123/_history/1. - Read:
GET [base]/Patient/123. Returns404if it never existed and410 Goneif it was deleted. - Update:
PUT [base]/Patient/123with the full resource, including"id": "123". Update replaces the whole resource; elements you leave out are removed. - Patch:
PATCH [base]/Patient/123withContent-Type: application/json-patch+jsonand 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=representationasks the server to return the saved resource;return=minimalreturns only headers.If-Match: W/"3"makes an update conditional on the current version (optimistic locking). If someone else changed the resource, you get412 Precondition Failedinstead of silently overwriting their work.
FHIR search basics
A search is GET [base]/[type]?param=value¶m=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-01GET [base]/Observation?patient=Patient/123&code=http://loinc.org|8867-4,http://loinc.org|8480-6GET [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,codealone, orsystem|for any code in a system:identifier=https://clinic.example.de/patient-id|PAT-001. - reference — links:
subject=Patient/123orpatient=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=mustermatches "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=...andcode: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=trueor_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-01and_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-responseBundle with oneresponse.statusper entry. - transaction — all entries succeed or none do. Entries can reference each other through temporary
urn:uuid:ids infullUrl, 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'sresponse.statusif 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
412for a staleIf-Match,409for a serialisation conflict, or400for a detected deadlock. Retry409and deadlocks; re-read before retrying412.
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]/Patientwith headerIf-None-Exist: identifier=https://clinic.example.de/patient-id|PAT-001. No match: the resource is created. One match: the server returns200 OKand creates nothing. Several matches:412. - Conditional update:
PUT [base]/Patient?identifier=https://clinic.example.de/patient-id|PAT-001with the resource as the body. No match creates it, one match updates it, several matches fail with412. - 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 Foundand410 Gone— unknown or deleted resource.409 Conflictand412 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
- Read the CapabilityStatement at startup or build time and fail loudly if a parameter you rely on is missing.
- Use
POST _searchfor queries that contain personal data. - Follow next links; never assume one page is complete.
- Use conditional create or update keyed on identifiers for anything that may be retried.
- Use transactions for multi-resource writes, and know your server's limits.
- Send
If-Matchon updates edited by humans. - Parse OperationOutcome and handle
429with backoff. - 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.
What does _include do in FHIR search?
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.
Does FHIR search support full-text search?
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.