Netherlands - Generic Functions for data exchange Implementation Guide
0.3.0 - ci-build
Netherlands - Generic Functions for data exchange Implementation Guide, published by Stichting Nuts. This guide is not an authorized publication; it is the continuous build for version 0.3.0 built by the FHIR (HL7® FHIR® Standard) CI Build. This version is based on the current content of https://github.com/nuts-foundation/nl-generic-functions-ig/ and changes regularly. See the Directory of published versions
Status: discussion document. This page is a working-group exploration, not a specification. Nothing on it is confirmed. It exists to answer the question at which granularity should SubscriptionTopics be defined for subscriber-initiated Notified Pull (without a workflow).
The working group has to decide at which granularity a Subscriber can scope a subscription in pure Notified Pull. We will make use of 4 "levels" of granularity:
| Level | Granularity | Example |
|---|---|---|
| L1 | FHIR resource type, optionally narrowed by a query | All Observation?category=laboratory for patient X |
| L2 | Data category (zorgcontext) | All medication data for patient X |
| L3 | Named canonical dataset | The BgZ of patient X |
| L4 | Per-case set, curated by the initiator | The dataset behind one specific referral (sender-initiated; not applicable to subscriber-initiated NP) |
Letting a Subscriber define topics freely was already recognized as a risk: unpredictable server load and an unclear query support across Dutch servers. As the anatomy section below shows, the chosen notification mechanism rules free definition out anyway so the open question is purely at which granularity the, nationally published set of topics is defined.
This page works that question through a use-case: a GP practice wants to stay current on the BgZ data that a hospital holds for a shared patient. Every scenario answers the same questions for a different topic granularity: which topics must exist, what the GP must subscribe to to cover the BgZ, what it gets notified on (and not), what the receiving system has to do per notification, and what it costs both sides.
Scenarios are presented in order of granularity, coarsest first. At the end there will be a comparison. A note on authorization and Access policy: theoratically this can be used to limit sets of data, but that is not how we assume it is used. It can veto an event, never define scope (see Topic matching and access evaluation).
A note on the backport: topics are implemented by hand, so a contract could technically demand
anything. With forward compatibility in mind we limit topics to the R5/R6 SubscriptionTopic
solution space (see Forward compatibility).
This IG uses the Subscriptions R5 Backport on FHIR R4 (see Notification). Under that combination, a topic is less of a runtime object than the name suggests:
Subscription.criteria; per the Backport profile, "the primary criteria is always the topic,
indicated by its canonical URL".SubscriptionTopic JSON as a machine-readable reference. It is documentation, not something a
server stores and interprets at runtime.canFilterBy. A topic declares which filter parameters a
Subscriber may set; the Subscription carries them in the backport-filter-criteria extension.
Per the Backport, filter keys "can be either search parameters appropriate to the filtering
resource or keys defined within the subscription topic".Two consequences frame everything that follows:
One more distinction matters before the scenarios make sense. Granularity design has two independent properties:
The same event stream can be offered as many narrow topics or as one broad topic with a
canFilterBy parameter. The events on the wire are identical; what changes is bookkeeping: one
Subscription per topic, and each Subscription is its own channel with its own event-number
sequence, handshake and heartbeats. Several scenarios below differ only in this second dial.
Between a change at the source and a notification at the Subscriber sit two independent gates, in series:
id-only notification disclosing only "an Observation exists for patient X" is a
disclosure, so the source must know or evaluate it per event against the related policy it has for this
Subscriber and context.The second gate is not optional: servers "SHOULD ensure that authorization is (still) in place when sending any event notifications"; shielded data "cannot generate a notification event" at all (R5 Subscriptions, Backport safety). Suppression of events is silent: the Subscriber cannot distinguish "nothing happened" from "withheld".
A source could technically narrow a broad topic to "what it considers relevant" by authz-policy. That breaks the contract and voids any completeness claim; best practice scopes by contract and suppresses only on patient opt-out. This page defines the gate's role as follows:
Subscription.status = off; the
Subscriber learns it via $status or a final notification. HL7 defines no mechanism here; the
convention is ours to write.| Actor | Properties |
|---|---|
| Source / Subscription Server | Hospital EHR, URA 22222222, FHIR endpoint https://hospital-ehr.example.org/fhir |
| Subscriber / Subscription Client | GP system, URA 33333333, notification endpoint https://gp-his.example.org/notifications |
| Patient | BSN 999911120, under treatment at the hospital, enrolled at the GP |
This is pure Notified Pull: no order, or workflow Task. The Subscription is created in-band: the GP system POSTs it to the hospital's FHIR endpoint, citing a topic canonical URL and filters. Subscriptions are per patient; the partner-wide variant (one Subscription for all shared patients) is treated once in Interest direction.
Every scenario below allows the same patient filter: limit events to one patient, identified
by BSN. It is defined once, nationally, and referenced from each topic's canFilterBy.
The patient filter parameter is not a standard search parameter: many resource types lack one,
and some (Medication, Practitioner) have no patient link at all. The topic backs it with a
national SearchParameter, referenced by filterDefinition, one expression branch per resource
type:
{
"resourceType": "SearchParameter",
"url": "http://fhir.nl/SearchParameter/patient-identifier",
"name": "PatientIdentifier",
"status": "draft",
"code": "patient",
"base": ["Patient", "Observation", "Condition", "Encounter", "Coverage"],
"type": "token",
"description": "The patient a resource is about, matched on identifier (BSN).",
"expression": "Patient.identifier | Observation.subject.identifier | Condition.subject.identifier | Encounter.subject.identifier | Coverage.beneficiary.identifier"
}
An R5/R6 server evaluates the expression against the changed resource and token-compares the
result to the filter value (bsn|999911120); the backport implements the same contract by hand.
The expression matches the BSN carried on the reference itself, which nl-core profiles populate;
a server may instead resolve the reference and match Patient.identifier. Chained parameters
(patient.identifier=...) exist in no FHIR version for subscription filters. Resources outside
every patient compartment never produce events, at any granularity.
L0 is on this page because it is the base case with the coarsest possible topic with one topic for everything. It carries the full wire-level components (topic contract, Subscription, notification) and allows us to show the basic shape of a subscription and build the following scenario's from it by only showing the difference.
What a national publication of this topic would amount to:
| Canonical URL | http://fhir.nl/SubscriptionTopic/patient-record-changed |
| Event | Any create, update or delete of any resource in the patient compartment |
Allowed filters (canFilterBy) |
patient |
| Notification payload | id-only (see Notification) |
As a full machine-readable reference, in FHIR R5 SubscriptionTopic form (remember: R4 itself has no SubscriptionTopic resource).
{
"resourceType": "SubscriptionTopic",
"id": "patient-record-changed",
"url": "http://fhir.nl/SubscriptionTopic/patient-record-changed",
"version": "0.1.0",
"title": "Patient record changed (L0 catch-all)",
"status": "draft",
"experimental": true,
"description": "Fires on any change to any resource in a patient compartment at the source. Boundary case for the granularity discussion; not a proposal.",
"resourceTrigger": [
{
"resource": "http://hl7.org/fhir/StructureDefinition/Observation",
"supportedInteraction": ["create", "update", "delete"]
},
{
"resource": "http://hl7.org/fhir/StructureDefinition/Condition",
"supportedInteraction": ["create", "update", "delete"]
},
{
"resource": "http://hl7.org/fhir/StructureDefinition/Encounter",
"supportedInteraction": ["create", "update", "delete"]
}
],
"canFilterBy": [
{
"description": "Limit events to a single patient, identified by BSN.",
"filterParameter": "patient",
"filterDefinition": "http://fhir.nl/SearchParameter/patient-identifier"
}
]
}
Shown are three of the triggers. There is no wildcard in any FHIR version, so the normative topic enumerates one trigger per resource type in the patient compartment (60+). "Everything" is the most expensive contract to write down.
The GP system POSTs one Subscription to https://hospital-ehr.example.org/fhir/Subscription
(diagram step 1). The topic URL goes in criteria and the patient filter in a backported
extension:
{
"resourceType": "Subscription",
"meta": {
"profile": [
"http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription"
]
},
"status": "requested",
"reason": "GP keeps its record current for a shared patient",
"criteria": "http://fhir.nl/SubscriptionTopic/patient-record-changed",
"_criteria": {
"extension": [
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
"valueString": "patient=http://fhir.nl/fhir/NamingSystem/bsn|999911120"
}
]
},
"channel": {
"extension": [
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-heartbeat-period",
"valueUnsignedInt": 86400
}
],
"type": "rest-hook",
"endpoint": "https://gp-his.example.org/notifications",
"payload": "application/fhir+json",
"_payload": {
"extension": [
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-payload-content",
"valueCode": "id-only"
}
]
}
}
}
The hospital validates the request against the topic contract (is the topic URL supported? is
patient an allowed filter? may this Subscriber follow this patient?), stores the
Subscription with status = requested, performs the handshake to the GP's notification endpoint,
and flips the status to active (steps 2-5). From here on the channel is guarded by heartbeats:
silence longer than the agreed period tells the GP to check $status.
A week later a lab result lands in the hospital EHR (step 6): a new Observation with category
laboratory. The write matches the topic (any resource, this patient), so the hospital POSTs a
notification Bundle to the GP's endpoint (step 7):
{
"resourceType": "Bundle",
"meta": {
"profile": [
"http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription-notification-r4"
]
},
"type": "history",
"timestamp": "2026-06-04T14:12:09+02:00",
"entry": [
{
"fullUrl": "urn:uuid:1d3c5b7a-9e2f-4c80-b1a6-3f5d7e9c2b4a",
"resource": {
"resourceType": "Parameters",
"meta": {
"profile": [
"http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-subscription-status-r4"
]
},
"parameter": [
{
"name": "subscription",
"valueReference": {
"reference": "https://hospital-ehr.example.org/fhir/Subscription/63f0a7d2-44cd-4f54-9c1e-2b8e1d0a9b3f"
}
},
{ "name": "status", "valueCode": "active" },
{ "name": "type", "valueCode": "event-notification" },
{
"name": "notification-event",
"part": [
{ "name": "event-number", "valueString": "7" },
{
"name": "timestamp",
"valueInstant": "2026-06-04T14:12:09+02:00"
},
{
"name": "focus",
"valueReference": {
"reference": "https://hospital-ehr.example.org/fhir/Observation/d9c3b2a1-5e64-4f00-8c11-7aa2f60b3c55"
}
}
]
}
]
},
"request": {
"method": "GET",
"url": "https://hospital-ehr.example.org/fhir/Subscription/63f0a7d2-44cd-4f54-9c1e-2b8e1d0a9b3f/$status"
},
"response": { "status": "200" }
}
]
}
With id-only payload the Bundle carries no clinical content, but it is not anonymous either: the
focus URL alone tells the GP that an Observation now exists for this patient. The notification
itself is a disclosure and must pass the same authorization policy as a read.
Per incoming notification (steps 8-9):
event-number continuity. This Bundle says 7; if the GP last processed 5, event 6
was missed and is recovered via the $events operation on the Subscription.Observation) is known before any pull.GET the focus resource and classify it after the fact.This is the L0 summary in one line: the channel is trivial to set up but the topic does not provide any relevance filtering. That is a job of the subscriber after the pull.
.../Condition/... from a psychiatric department discloses something.The national set publishes one topic per FHIR resource type: observation-changed,
condition-changed, encounter-changed, and so on. Filters narrow within the type: all
laboratory Observations for patient X.
One representative topic; the other types follow the same shape.
| Canonical URL | http://fhir.nl/SubscriptionTopic/observation-changed |
| Event | Any create, update or delete of an Observation |
Allowed filters (canFilterBy) |
patient, category, code |
| Notification payload | id-only |
Unlike L0, category and code are plain Observation search parameters; only patient
stays topic-defined, backed by the same national SearchParameter.
One Subscription cites one topic. Covering the BgZ takes 19 Subscriptions per patient: 19 handshakes, 19 event-number streams, 19 heartbeat timers. Flow and channel settings are identical to L0.
| Topic | Filter besides patient | BgZ content covered | Notes |
|---|---|---|---|
patient-changed |
- | Patient info, contacts, GP | Fires on any demographic change |
consent-changed |
category (2 codes) |
Treatment/advance directives | |
observation-changed |
category (lab, vitals, …) |
Lab, vitals, functional/social | BgZ selects some observations by code; a category filter over-notifies |
medication*-changed (3 topics) |
- | Medication use, agreements, dispenses | One BgZ section, three topics |
procedure-changed |
category (surgical) |
Procedures | |
encounter-changed |
class (IMP, ACUTE, NONAC) |
Encounters | Without the class filter: every outpatient contact |
servicerequest-changed |
- | Planned procedures | STU3 BgZ uses ProcedureRequest; R4 equivalent is ServiceRequest |
Ten rows without distinguishing filters omitted for brevity (allergies, alerts, problems, coverage, devices, vaccinations, nutrition, planned care).
The Subscription differs from L0 only in criteria. Multiple filter extensions AND together;
commas OR within one parameter (backport search syntax; undefined in R5/R6):
"criteria": "http://fhir.nl/SubscriptionTopic/observation-changed",
"_criteria": {
"extension": [
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
"valueString": "patient=http://fhir.nl/fhir/NamingSystem/bsn|999911120"
},
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
"valueString": "category=http://terminology.hl7.org/CodeSystem/observation-category|laboratory,http://terminology.hl7.org/CodeSystem/observation-category|vital-signs"
}
]
}
As L0, with two differences:
event-number sequences, 19 heartbeat timers,
19 $events catch-up paths.Relevance is still settled after the pull: a category filter cannot see that BgZ wants only the
most recent observation ($lastn) or only specific codes.
Medication resources sit
outside the patient compartment and never notify; their content comes with the pull.category is wider than
BgZ's code lists, and every update of a matching resource notifies, also where the BgZ snapshot
would ignore it.observation-changed, and the dataset it would bind to
is not named anywhere.The national set adopts the
GF data categories (zorgcontext):
28 codes used to publish and localize patient data in the national localization index, intended
to cover all patient data. Two subscription variants exist: 28 topics, one per category,
subscribed like L1; or one topic with a data-category filter. Events are identical; the
single-topic variant is worked out here.
| Canonical URL | http://fhir.nl/SubscriptionTopic/patient-data-changed |
| Event | Any change to a resource that maps to a data category |
Allowed filters (canFilterBy) |
patient, data-category |
| Notification payload | id-only |
The contract's bulk is the mapping: per category, which resource types and conditions belong to
it. Nine of the 28 codes are Observation split by category, most others are single resource
types, a few span types (Request, Imaging, Episode). Whether one resource may map to
several categories is not specified (value set v0.10.0).
One Subscription per patient. The category list for the BgZ:
data-category filter value |
BgZ content | Notes |
|---|---|---|
Patient |
Patient info, contacts, GP | |
AdvanceDirective, Consent |
Treatment/advance directives | |
ObservationLaboratory, ObservationVitalSigns, ObservationSocialHistory |
Lab, vitals, lifestyle | |
MedicationRequest, MedicationUse |
Medication | Where dispenses map is unclear |
Condition, AllergyIntolerance, Alert, Procedure, Encounter, Device, Nutrition |
Problems, allergies, alerts, procedures, encounters, aids, nutrition | Type-shaped, mapping trivial |
Request |
Planned care | One category, five resource types |
| none | Functional status | No fitting category (ObservationExam? ObservationSurvey?) |
| none | Payment details | No category for Coverage |
| none | Vaccinations | No category for Immunization |
Sixteen categories cover what they can; three BgZ sections have no home. The Subscription
differs from L0 in criteria and the second filter:
"criteria": "http://fhir.nl/SubscriptionTopic/patient-data-changed",
"_criteria": {
"extension": [
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
"valueString": "patient=http://fhir.nl/fhir/NamingSystem/bsn|999911120"
},
{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
"valueString": "data-category=Patient,Condition,AllergyIntolerance,ObservationLaboratory,ObservationVitalSigns,MedicationRequest,MedicationUse,Encounter,Procedure,Request"
}
]
}
As L1, on a single stream. The notification does not say which category fired; the resource type
comes from the focus URL, classification comes after the pull. Per-type narrowing is gone: the
data-category filter cannot carry an encounter class or observation code, so every
encounter notifies, not only admissions.
Imaging, Genomics, Episode, Request. The classification must be
identical at every source, or the same subscription means different things.The 28-topic variant: one topic per category, generated from the same mapping table the way
L3a generates from the PlanDefinition; each topic expresses its category as ordinary triggers
and reuses its type's search parameters (class, code) as filters, so per-type narrowing
returns. No comma lists, no custom mapping at the source, portable to R5/R6. The price is
L1-style bookkeeping: 16 Subscriptions and 16 event streams per patient. The scores table
carries both forms.
The national set publishes one topic per canonical dataset. For the BgZ that is a single topic that fires when the patient's BgZ changes; eOverdracht and medicatiegegevens would get their own.
"The BgZ changed" can mean two things as an event, and the choice defines the topic:
| Canonical URL | http://fhir.nl/SubscriptionTopic/bgz-changed |
| Event | Any change to a resource in the patient's BgZ |
Allowed filters (canFilterBy) |
patient |
| Notification payload | id-only |
The trigger list mirrors the BgZ section queries, one trigger per section:
"resourceTrigger": [
{
"resource": "http://hl7.org/fhir/StructureDefinition/Observation",
"supportedInteraction": ["create", "update"],
"queryCriteria": {
"current": "category=http://terminology.hl7.org/CodeSystem/observation-category|laboratory",
"resultForCreate": "test-passes"
}
},
{
"resource": "http://hl7.org/fhir/StructureDefinition/Encounter",
"supportedInteraction": ["create", "update"],
"queryCriteria": {
"current": "class=http://terminology.hl7.org/CodeSystem/v3-ActCode|IMP,http://terminology.hl7.org/CodeSystem/v3-ActCode|ACUTE,http://terminology.hl7.org/CodeSystem/v3-ActCode|NONAC"
}
}
]
Seventeen more triggers follow the same pattern. They are not hand-maintained: the trigger list
is the dataset's PlanDefinition query list recast as
write-time predicates, and can be generated from it. Pull-time constructs (_include, $lastn)
are stripped, supportedInteraction (including delete) is added. One published query list then
powers the receiver's pull, the source's triggers and, where wanted, the L3b view: the dataset
definition evaluated forwards and backwards.
The Subscription the GP creates is identical to
the L0 example, with criteria = http://fhir.nl/SubscriptionTopic/bgz-changed: one per patient,
one handshake, one event stream, one heartbeat timer.
The receiver works as in L1, on a single stream. Every event is BgZ-relevant by contract; the
pull still decides what to do with it. Trigger fidelity has limits: "most recent only"
($lastn) and _included resources stay pull-time concerns.
| Canonical URL | http://fhir.nl/SubscriptionTopic/bgz-published |
| Event | The source publishes or updates the patient's BgZ container |
Allowed filters (canFilterBy) |
patient |
| Notification payload | id-only; focus references the container |
The container is the List from the
Notified Pull container pattern. Two
publication policies:
List.entry instead of classifying resources.The receiver reads the container and diffs it against its own cached copy; additions and
removals both surface without _history, which facade servers rarely offer. The focus URL leaks
no resource type: an event says only "the BgZ changed".
The container is a view on the dataset: materialized by executing the PlanDefinition queries, maintained only while subscriptions are active. No subscribers, no view; the standing cost for unsubscribed patients and datasets is zero. Continuous publication then costs L3a's trigger evaluation plus a List write.
queryCriteria search strings are runtime configuration in
R5 and R6 unchanged; R6 only renames resourceTrigger to trigger.
A standing subscriber cannot subscribe to a case that does not exist yet, so L4 is not a subscription granularity for the anchor case. Per-case curation is sender-initiated: the source selects resources for one exchange and notifies the recipient.
The transport still fits one generic topic: curated-set-published, firing when the source
publishes a container addressed to a recipient. The Subscription is partner-wide and permanent
(canFilterBy recipient); the per-case scope lives in the container, not in the topic. This is
the Notified Pull container pattern; with a
workflow above it, the Clinical Workflow profile. For L4 the
granularity question dissolves: the topic names the exchange pattern, the sender names the data.
A subscription setup answers two questions: what data, and for whom. The scenarios above put
both in the Subscription: the topic names the data, the patient filter names the subject; the
subscriber asserts its own interest. Sender-initiated exchanges answer "for whom" in the data:
the focal resource carries the addressee, and one permanent partner-wide Subscription per
partner delivers everything addressed to it. This IG already uses that form: the
notification page filters partner-wide on Task.owner.
| Receiver-asserted (L1-L3) | Sender-addressed (L4, workflow Task) | |
|---|---|---|
| Interest declared | In the Subscription (topic + patient filter) |
In the data (Task.owner, recipient on a container) |
| Subscription shape | One per patient and dataset | One per partner, permanent |
| Focal resource | Shared per patient and dataset; no recipient | Per case, per recipient |
| Partner-wide filter | Not expressible (cross-resource join) | A plain filter on the focal resource |
The directions do not mix: one exchange has one interest holder. Sender addressing fits where the sender holds the relationship (it curates a set for a known recipient); receiver assertion fits where interest is the receiver's own call. The container is what enables L4: the sender can put in it whatever the case needs, beyond any canonical dataset.
Clinical Workflow needs both directions at once. The Coordination Task is the sender-addressed stream. Whether the Fulfiller also wants updates on the case's dataset while the workflow runs is its own call: a receiver-asserted subscription, created at acceptance, ended at completion. That data stream is no new mechanism; it is this page's subscriber-initiated subscription with a workflow-bounded lifetime. The granularity chosen here is therefore also the shape of the COW data channel.
Scores trace to the scenario observations. L3b cells split continuous / moment-based where the publication policy matters.
| Axis | L0 | L1 | L2 (1 topic) | L2 (28 topics) | L3a | L3b |
|---|---|---|---|---|---|---|
| BgZ completeness | ++ | ++ | - | - | ++ | ++ / 0 |
| Noise | – | 0 | - | 0 | ++ | ++ |
| Subscriber cost | - | – | + | – | + | ++ |
| Source cost | - | 0 | - | 0 | 0 | - |
| Intent | – | – | 0 | 0 | ++ | ++ |
| Authorization binding | – | – | + | + | ++ | ++ |
| Governance | ++ | + | 0 | 0 | 0 | 0 |
| Privacy | – | 0 | 0 | 0 | 0 | ++ |
| Forward compatibility | – | + | - | ++ | ++ | ++ |
In the backport, topics are implemented by hand and a contract can say anything. From R5 on the topic is a resource the server evaluates as configuration; what cannot be expressed there will not survive the transition.
One rule explains most of the table below: R5/R6 reward semantics in triggers and punish
semantics in filters. A trigger carries full query semantics (queryCriteria), runs on the
server's standard search machinery, and is identical for every subscriber, so it ships as
configuration. A filter is evaluated per Subscription by comparing the subscriber's value with
what a FHIRPath expression reads from the one changed resource; anything not readable from that
resource (a category mapping, compartment membership, a chain) needs custom implementation.
Topics that put their meaning in triggers (L1, L2's 28-topic form, L3) travel; the single-topic
L2 puts its meaning in a filter and does not. Per construct used above:
| Construct | R5 / R6 |
|---|---|
| Wildcard trigger (L0) | Not expressible; no compartment triggers, enumerate every type |
Cross-resource patient filter (all) |
Expressible via a national SearchParameter in filterDefinition; chaining is not |
| Comma-OR filter values (L1, L2) | Undefined; multiple filters AND |
data-category filter (L2, 1 topic) |
No resource carries its category; mapping is custom per source. 28-topic form: triggers |
queryCriteria triggers (L3) |
Fully runtime-evaluable; R6 renames resourceTrigger to trigger |
status = off, final
notification)? Needed in every scenario; defined nowhere.patient SearchParameter that the topics reference?