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
OperationOutcomewith one issue:issue.codefrom the FHIR IssueType value set (login401,forbidden403,not-found404,invalid/not-supported400,throttled429,exception500) andissue.details.textcarrying the stable Clive error code partners can branch on (INVALID_TOKEN,INSUFFICIENT_SCOPE,CONSENT_REQUIRED,NOT_FOUND, ...).diagnosticsis 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_logsrow (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_logand anaudit_logsdata_shareentry).
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 |
Consent gate
| 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.
Consent
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[]withfullUrl=<base>/<type>/<id>andsearch.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
collectionBundle (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), onecollectionBundle 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.