Data and trust

FHIR R4

The read-only HL7 FHIR R4 API: endpoints, scopes, the consent gate and how each resource is mapped.

Clive exposes a read-only FHIR R4 (4.0.1) view of members, appointments, consents and released lab results to partners, and can export a member's record as a FHIR collection Bundle. This document is the contract: what each FHIR element is built from, which codes are used, and what is deliberately left out. The code is in apps/api/src/features/fhir/ (mappers.ts builds the resources, codes.ts holds every code system and value map, schema.ts the structural validators).

Out of scope: writes of any kind, Encounter, Practitioner, Location, Organization, ServiceRequest, subscriptions, _history, _revinclude, chained search, and SMART-on-FHIR launch.

Endpoints

Base: /api/v1/fhir/r4

Interaction Request Response
capabilities GET /metadata (no auth) CapabilityStatement
read GET /{type}/{id} the resource
search GET /{type}?{params} Bundle of type searchset

{type} is one of Patient, Appointment, Consent, DiagnosticReport, Observation. Any other method on these paths answers 405 with an OperationOutcome.

  • Media type: every response is application/fhir+json; charset=utf-8.
  • Errors: every non-2xx body is an OperationOutcome with one issue: issue.code from the FHIR IssueType value set (login 401, forbidden 403, not-found 404, invalid / not-supported 400, throttled 429, exception 500) and issue.details.text carrying the stable Clive error code partners can branch on (INVALID_TOKEN, INSUFFICIENT_SCOPE, CONSENT_REQUIRED, NOT_FOUND, ...). diagnostics is human-readable.
  • Auth: OAuth 2.0 client credentials, the same clients and tokens as the partner API (POST /oauth/token, Authorization: Bearer ...). The per-client rate limit and revocation apply.
  • Audit: every successful read and search writes an audit_logs row (action = READ, the API client as actor, details.fhir = resource type, details.interaction = read / search-type / search-include). Consent refusals are written by the consent gate (data_share_log and an audit_logs data_share entry).

Scopes

FHIR is another representation of the same records, so it uses the existing data scopes rather than a separate fhir:read scope (which would let a client holding only that scope read results it was never granted):

Resource Scope
Patient members:read
Appointment appointments:read
Consent consents:read
DiagnosticReport, Observation results:read
Resource Purpose checked Rule
DiagnosticReport, Observation share_results_<client provider> (e.g. share_results_partner_v1; share_results_partner for a client without a provider) explicit grant required
Patient, Appointment care allowed unless withdrawn
Consent not gated a withdrawal must always reach the partner

A read of one member's resource, or a search with patient= / subject=, answers 403 CONSENT_REQUIRED when the gate refuses. A search across members leaves out the members the gate refuses (the page may then hold fewer than _count entries; keep following next). Every decision, allowed or blocked, is written to data_share_log with event type fhir.<Resource>.<read|search>. Consent is read live, so a withdrawal stops the very next request.

Released results only

Only lab orders with status released (after review, via release_lab_order) and the results of those orders are visible. An unreleased, withheld or cancelled order, and its results, answer 404 on read exactly like an id that does not exist, and never appear in a search, an _include or an export. Released results can no longer be amended, so every exposed DiagnosticReport and Observation has status final.

Erased (deleted) and merged members are not exposed, nor is anything belonging to them.

Search parameters

Common to every type:

Parameter Meaning
_id one id or a comma-separated list
_lastUpdated ge, gt, le or lt + a FHIR dateTime; repeatable for a range. _lastUpdated=ge<t> is exactly the partner API's updated_since=<t>. A bare value or eq is refused (400) rather than silently treated as ge
_count page size, 1 to 200 (default 50)
_cursor opaque keyset cursor; never build it, follow the next link

Per type:

Type Parameters
Patient identifier (<system>|<value> or <value>: membership number or partner id, systems below), active
Appointment patient, status (FHIR code), date (ge/gt/le/lt, on the start time)
Consent patient, status (active / inactive)
DiagnosticReport patient or subject (<id> or Patient/<id>), _include=DiagnosticReport:result
Observation patient or subject

Any other parameter is refused with 400 not-supported instead of being ignored, so a misspelt filter never widens a search.

Ordering and paging. Results are ordered by (meta.lastUpdated, id) ascending, so a search is also a change feed: page with _lastUpdated=ge<t>, follow link[relation=next] until it is absent, remember the last meta.lastUpdated, and resume from it. The Bundle has link self and (when there are more) next; total is not returned. entry.search.mode is match for matches and include for _included Observations.

Identifier and code systems

Clive-defined systems live under https://clivehealth.app/fhir. They are identifiers, not links, and never change.

Use System
Membership number https://clivehealth.app/fhir/sid/membership/<provider>
Partner's own member id https://clivehealth.app/fhir/sid/external-id/<provider>
Identity document https://clivehealth.app/fhir/sid/<identity_type> (mykad, mypr, mykas, mytentera, passport, other)
Practitioner / venue (by identifier) https://clivehealth.app/fhir/sid/practitioner, .../sid/venue
Lab order https://clivehealth.app/fhir/sid/lab-order
Global lab test codes https://clivehealth.app/fhir/CodeSystem/lab-test (FBC, RP, ...)
Global analyte codes https://clivehealth.app/fhir/CodeSystem/lab-analyte (<TEST>.<analyte-slug>, e.g. FBC.haemoglobin)
A clinic's own tests / analytes https://clivehealth.app/fhir/CodeSystem/clinic/<clinic id>/lab-test, .../lab-analyte
Consent purpose https://clivehealth.app/fhir/CodeSystem/consent-purpose
Privacy notice (policy) https://clivehealth.app/fhir/privacy-notice/<version>
Our appointment status extension https://clivehealth.app/fhir/StructureDefinition/appointment-status

Patient

From patients (+ decrypted encrypted_pii) and patient_external_ids.

FHIR Source Notes
id patients.id
meta.lastUpdated patients.updated_at
identifier[] (membership) patient_external_ids.membership_no use=secondary, type = v2-0203 MB (Member Number), system .../sid/membership/<provider>, assigner.display = provider. One per provider that has a number
identifier[] (partner id) patient_external_ids.external_id use=secondary, system .../sid/external-id/<provider>
identifier[] (IC / passport) PII identityNumber, patients.identity_type, identity_country use=official, system .../sid/<identity_type>, type = v2-0203 NI (MyKad), PPN (passport), PRC (MyPR), MI (MyTentera); MyKAS and other carry no type code. assigner.display = issuing country when known
active status = 'active' inactive members are false
name[0].text PII fullName use=official. Stored as one string, so no family/given split
telecom[] PII phone (mobile), email
gender PII gender when present male / female / other / unknown
birthDate PII dateOfBirth only when a valid YYYY-MM-DD
communication[0] preferred_language BCP-47 (ms, en), preferred=true

Not exported: address, emergency contact, notes, blind indexes, encrypted blobs (parity with the Members API). When the PII cannot be decrypted the PII-derived elements are omitted, never guessed.

Appointment

From appointments.

FHIR Source
id, meta.lastUpdated id, updated_at
status status map below
extension[appointment-status].valueCode our status, unmapped (so scheduled vs confirmed survives)
cancelationReason.text cancel_reason, when cancelled
serviceType[0] type (local code = slug, text = as entered)
start / end start_time / start_time + duration_minutes
minutesDuration duration_minutes
created created_at
participant[] the patient (actor = Patient/<id>), the practitioner (type = v3 ParticipationType PPRF, actor by identifier), the venue (Location, by identifier); all required / accepted

Status map (our state machine → Appointment.status):

Clive FHIR Why
scheduled booked slot reserved
confirmed booked FHIR has no separate confirmed state; the extension keeps ours
checked_in checked-in
in_progress arrived the member is in the clinic being seen; R4 has no in-progress status for Appointment (that is the Encounter's job)
completed fulfilled
cancelled cancelled
no_show noshow
anything unknown entered-in-error defensive; the mapping is total over our statuses and a unit test fails if one is added without a mapping

Search status=booked matches both scheduled and confirmed.

From consents (one row per member and purpose; history stays in consent_events and is not exposed as FHIR).

FHIR Source Notes
id, meta.lastUpdated id, updated_at
status granted → active, withdrawn → inactive
scope consentscope treatment for care, patient-privacy for every other purpose
category[0] LOINC 59284-0 Patient Consent
patient Patient/<patient_id>
dateTime granted_at (or withdrawn_at when withdrawn)
policy[0].uri notice_version → https://clivehealth.app/fhir/privacy-notice/<version> the privacy notice version in force for the clinic when the member consented
policyRule v3-ActCode OPTIN only when there is no notice version (consents recorded before notice versions were kept), because R4 invariant ppc-1 needs a policy or a policyRule
provision.type permit
provision.period granted_at → withdrawn_at the end is set once withdrawn
provision.purpose[] purpose our code in .../CodeSystem/consent-purpose, plus the v3 PurposeOfUse equivalent where one exists: care → TREAT, marketing_* → HMARKT. share_results_<provider> has no standard equivalent

DiagnosticReport

From a released lab_orders row, its lab_order_items (→ lab_test_catalog) and its lab_results.

FHIR Source Notes
id, meta.lastUpdated lab_orders.id, updated_at
identifier[0] .../sid/lab-order + order id
status final only released orders are exposed
category[0] v2-0074 LAB
code one test: its LOINC panel code when the catalogue has one (FBC → 58410-2, LFT → 24325-3) plus our test code; several tests: LOINC 11502-2 Laboratory report, text = the test names
subject Patient/<patient_id>
effectiveDateTime earliest result observed_at, else the order's created_at specimen time is not recorded
issued released_at when it was made available
performer[0].display lab_name the reference lab, by name
result[] Observation/<lab_results.id> for every result of the order ordered by observed time, analyte
conclusion release_note the releasing doctor's note to the member

To get the report and its Observations in one call: GET /DiagnosticReport?_id=<id>&_include=DiagnosticReport:result (or ?patient=<id>&_include=DiagnosticReport:result). The included Observations are the ones result references.

Observation

From a lab_results row of a released order.

FHIR Source Notes
id lab_results.id
meta.lastUpdated lab_results.updated_at bumped when the order is released (migration 20261026400000_fhir.sql), so _lastUpdated picks a result up when it becomes visible
status final
category[0] observation-category laboratory
code.coding[] LOINC (when the catalogue lists one for the analyte) and always the local code text = analyte name
subject the order's patient
effectiveDateTime observed_at
issued the order's released_at
valueQuantity value_numeric + units UCUM system/code when the unit is in our UCUM map (g/dL, mmol/L, µmol/L → umol/L, x10^9/L → 10*9/L, IU/L → [IU]/L, ...); otherwise unit text only
valueString value_text qualitative results (blood film, serology)
note[0].text value_text when there is also a numeric value
dataAbsentReason unknown only when neither value is present
interpretation[0] flag → v3 ObservationInterpretation L / H / N / A
referenceRange[0] reference_low / reference_high in the result's units the range the result was flagged against (the catalogue default or the clinician's override); open-ended ranges carry only low or high

LOINC

LOINC codes live in the catalogue: lab_test_catalog.loinc_code for the panel and reference_range.analytes[].loinc per analyte, seeded for the global catalogue by 20261026400000_fhir.sql from LOINC_CODES in apps/api/src/features/labs/catalog.ts (a unit test keeps the two identical and checks every code's LOINC check digit).

The mapping is deliberately conservative: a code is used only where it is unambiguous for the units and specimen in our catalogue, e.g. the molar creatinine code 14682-9 (µmol/L), not the mass one. Covered: the full blood count (all 12 analytes, panel 58410-2), ESR, reticulocytes, PT/APTT/INR, ABO and Rh, HbA2/HbF, ferritin, iron studies, B12 and folate, sodium, potassium, chloride, creatinine, the liver panel (panel 24325-3), total/HDL cholesterol and triglycerides, fasting and random glucose, HbA1c (% and IFCC), uric acid, calcium, phosphate, magnesium, amylase, lipase, CK, LDH, CRP, hs-troponin I, NT-proBNP, TSH, free T4, FSH, LH, β-hCG, the urine dipstick (SG, pH, protein, leukocytes, nitrite), urine hCG, HBsAg, anti-HBs, anti-HCV, HIV Ag/Ab, RF, IgE, PSA, CEA, CA 125, AFP and SARS-CoV-2 RNA.

Local code only (by design, until the reporting method is known): eGFR (equation-specific codes), LDL and non-HDL cholesterol (direct vs calculated), urea in mmol/L, corrected calcium, OGTT time points, free T3, 25-OH vitamin D, cortisol, testosterone, prolactin and insulin in SI units, the urine albumin:creatinine ratio, HbA (adult), the remaining dipstick and microscopy fields, dengue, typhoid, H. pylori, syphilis, ANA, cultures, cytology, HPV, every veterinary panel, and every clinic's own catalogue entries.

Bundles

  • searchset (API): id, meta.lastUpdated, timestamp, link (self, next), entry[] with fullUrl = <base>/<type>/<id> and search.mode.
  • collection (export, below): entry[].fullUrl = urn:uuid:<id> (our ids are UUIDs), and references between entries are rewritten to those URNs so the Bundle resolves on its own.

Export (format: 'fhir')

POST /api/v1/exports (staff), POST /public/v1/exports (partner) and DSAR access requests accept format: "fhir":

  • member scope: one collection Bundle (application/fhir+json, .json) with the Patient, their Appointments and Consents and, when the requester may see clinical content (view_results), one DiagnosticReport per released lab order with its Observations. Unreleased results are never exported in FHIR. Partner API exports never include clinical content (as for CSV/JSON).
  • clinic scope: NDJSON (application/fhir+ndjson, .ndjson), one collection Bundle per line per member, with Patient and Appointments: the same records as the clinic CSV/JSON export.

row_counts on the export counts resources by type.