FHIR R4 Subscriptions Are Delivery Semantics, Not Just Webhooks
FHIR R4 Subscriptions Are Delivery Semantics, Not Just Webhooks
Primary query: FHIR R4 subscriptions rest-hook websocket
Version: dev
Source revision: 7e036d413940d3978a5042409d387a4d52f2485c
Last verified: October 1, 2026
FHIR version: R4 4.0.1
Data: synthetic only
A FHIR Subscription is easy to underestimate.
It looks like a webhook feature: save some criteria, give the server an endpoint, and wait for HTTP requests. But the hard part is not making the request. The hard part is deciding what the server promises when two workers race, a process dies after sending, an endpoint times out, a subscriber goes offline, or a callback URL points back into your own network.
That is why Ilavrita treats subscriptions as delivery semantics, not as a thin HTTP integration.
At the current dev revision, Ilavrita accepts two FHIR R4 subscription channels: rest-hook and websocket. Rest-hook delivery is persisted, claimed by workers, retried, and explicitly at-least-once. WebSocket delivery is deliberately different: it reaches a subscriber connected to the current process, and an offline subscriber misses the event.
Those are not implementation details to hide. They are the contract an integration engineer needs to know before building on the feature.
Ilavrita remains a developer preview. Use synthetic data only.
The first rule: refuse subscriptions the server cannot honor
A subscription that is accepted but can never fire is worse than a rejected request.
Ilavrita therefore validates the operational parts of a Subscription when it is written. The current subscription reader checks the criteria, resource type, search parameters, channel, endpoint, and status before accepting the resource.
For example:
{
"resourceType": "Subscription",
"status": "active",
"criteria": "Observation?status=final",
"channel": {
"type": "rest-hook",
"endpoint": "https://example.test/fhir-events"
}
}
The criteria is not treated as an arbitrary string. Ilavrita parses it through the same search machinery the server actually implements.
That means criteria naming an unsupported resource type or unsupported search parameter are refused instead of being stored as a promise the notifier cannot keep. Paging parameters such as _count, _cursor, and _total are also rejected because a subscription describes a match, not a page of search results.
The same principle appears throughout Ilavrita: capability claims should be executable claims.
Source: packages/subscription.
A write and a notification are different durable facts
When a resource is written, the server does not need to synchronously call every subscriber before the FHIR request can finish.
Instead, the subscription pipeline separates two questions:
- Which subscriptions are owed a notification for this write?
- Which owed notifications are ready to be delivered?
That separation matters once more than one worker exists.
The current implementation maintains a backlog of writes that need to be fanned out. Matching produces delivery records. Workers then claim due work from the queue.
A worker claim has an identity and a lease.
That is the important part.
Without claims, two replicas reading the same queue can both decide that the same row belongs to them. The result is duplicate fan-out or two simultaneous deliveries to the same endpoint.
The current tests explicitly exercise that race: one worker claims the work, and another worker reading the same queue receives none of those claimed rows while the lease holds.
If the first worker dies, the lease expires and another worker can reclaim the work.
This is a much stronger model than “run a goroutine every few seconds and POST whatever is pending.”
Exactly-once delivery is the wrong promise
There is a failure no webhook sender can wish away.
Imagine Ilavrita sends a notification. The subscriber receives it, performs its action, and then the connection fails before Ilavrita receives the successful HTTP response.
Did delivery happen?
The subscriber says yes.
The sender cannot know.
If Ilavrita permanently marks that attempt failed and never retries, a real failure can lose a notification. If it retries, the subscriber may see the same notification twice.
So rest-hook delivery is at-least-once.
The current implementation makes that survivable by giving the delivery a stable identity. Rest-hook requests include an X-Delivery header, alongside subscription and resource identity.
A subscriber that needs once-per-event behavior should deduplicate by that delivery identifier.
That is more honest than advertising “exactly once” over HTTP.
The repository tests also pin the behavior: a retry of the same logical delivery keeps the same delivery identity rather than becoming a new event.
Stable identity also protects fan-out
There is another duplicate path before the HTTP request even happens.
A worker can fan a write out into delivery rows and then die before recording that the backlog item was settled. Another worker eventually reclaims the write and performs fan-out again.
If delivery IDs were random, the second fan-out could create another delivery for the same subscription and write.
Ilavrita instead derives delivery identity from the project, written event, and subscription. Re-running fan-out therefore finds the same logical delivery.
The tests intentionally fan the same write out repeatedly and assert that only one delivery remains owed.
Claims keep live workers apart.
Deterministic delivery identity handles replay after failure.
You need both.
Rest-hook retries are durable; WebSockets are not
It is tempting to describe rest-hook and websocket as two transports for the same queue.
They are not.
A rest-hook subscriber has an endpoint. The server can attempt delivery, record failure, back off, and retry later.
A WebSocket subscriber is a live connection.
The current code states the boundary directly: a subscriber that is not connected misses the WebSocket notification. A socket is not a durable queue.
That means WebSocket subscriptions are useful for live UI or session-oriented signaling, but they should not be interpreted as a replacement for durable event delivery.
The README currently summarizes the capability as:
rest-hook, andwebsocketwithin one process
The “within one process” qualification matters. A WebSocket bound to one Ilavrita process is not currently a cluster-wide subscription bus.
If your integration requires delivery across disconnects or process movement, use the semantics of the durable rest-hook path rather than assuming the WebSocket channel provides them.
Authorization is re-checked at delivery time
A subscription should not become a permanent bypass around authorization.
Ilavrita records who owns the subscription and evaluates matching under that authority. More importantly, the delivery path re-reads the resource before notification rather than blindly carrying a resource body from an old queue entry.
The reason is subtle: authorization can change between the original write and the later delivery attempt.
If the subscriber is no longer allowed to read the resource, a queued copy of the body would leak data that a normal FHIR read would now refuse.
So the queue carries identity and version information, not an authorization-free copy of the clinical resource.
The notifier asks again.
That makes subscription delivery follow the same security boundary as ordinary resource access instead of creating a second, weaker path around it.
A callback URL is an SSRF boundary
Rest-hook delivery means the server makes an outbound network request to a URL supplied through a FHIR resource.
That is an SSRF surface by definition.
Ilavrita restricts channels to HTTP and HTTPS endpoints, and its rest-hook delivery path refuses destinations that resolve into the host's own network by default. A deployment can explicitly opt into private-network hooks, but that is an operator decision rather than something a Subscription resource can silently enable.
This matters because syntax validation is not enough.
https://127.0.0.1/ is syntactically a perfectly good URL.
It can still be a terrible destination for a server-controlled callback.
The relevant tests cover loopback/private-network refusal and the explicit opt-in path.
What the current tests actually prove
The subscription work is useful because the tests are about failure semantics, not only the happy path.
At revision 7e036d4…, repository history includes tests for:
- refusing subscription criteria the server cannot execute,
- refusing unsupported channels and malformed endpoints,
- requiring only active subscriptions to deliver,
- preventing two workers from claiming the same backlog,
- allowing another worker to reclaim work after a lease expires,
- preventing repeated fan-out from owing duplicate deliveries,
- preventing two workers from making the same delivery concurrently,
- retrying failed rest-hook deliveries,
- preserving delivery identity across retry,
- preventing a WebSocket subscription from being delivered through the rest-hook channel,
- refusing outbound notification to the host's own network unless explicitly enabled.
The important takeaway is not that “Subscriptions work.”
It is narrower:
The current tests define concrete semantics for the two channels Ilavrita exposes, including several races and failure cases that a basic webhook implementation would ignore.
That is the claim we can make.
What we could not freshly execute here
I attempted the required clean clone in a fresh runtime:
git clone --branch dev --single-branch \
https://github.com/Ilavrita/Ilavrita.git \
/tmp/ilavrita-blog
The runtime could not resolve GitHub:
fatal: unable to access 'https://github.com/Ilavrita/Ilavrita.git/':
Could not resolve host: github.com
So I did not run a fresh local subscription test suite and do not claim that I did.
Instead, the connected GitHub repository API was used to inspect the current dev head and the commits that introduced subscription validation, WebSocket delivery, queue claims, stable delivery identity, retry behavior, and their tests.
The current head is:
7e036d413940d3978a5042409d387a4d52f2485c
The Payload-synced documentation metadata is still pinned to older revision 946221398bc8fa8731838e98e3d040c102df7b63. Current GitHub dev therefore wins where the synced copy lags.
There is also documentation drift
The evidence gate found a material contradiction worth making visible.
The current README describes authentication, audit, transactions, conditional interactions, SMART flows, subscriptions, and other capabilities as implemented.
But the introductory prose in api/openapi.yaml still says that only six single-resource interactions exist, that authentication is not implemented, and that the server keeps no audit trail.
That prose is stale relative to the current dev implementation and README.
The OpenAPI file itself even contains current authentication and SMART routes below that stale introduction, which makes the contradiction especially clear.
For runtime FHIR capability, clients should use the server's CapabilityStatement. For this post, claims were resolved against current dev source/tests rather than averaging the conflicting documents.
This drift should be fixed. It should not be repeated as product truth.
Practical guidance for an integration
If you are evaluating Ilavrita subscriptions today, start with the failure model.
Use synthetic data.
For a rest-hook consumer, treat X-Delivery as an idempotency key. Assume a notification can be repeated. Return success only after the action you need is durably accepted on your side.
For WebSockets, treat the channel as live signaling. Do not assume an event waits for you while disconnected, and do not assume a connection on one process represents a future cluster-wide event stream.
For either channel, test authorization changes between resource write and delivery. A subscription system is only safe if “I used to be allowed to read this” does not become permanent delivery authority.
And inspect the running server's CapabilityStatement before depending on broader FHIR behavior.
The webhook is the easy line of code
Sending an HTTP request is not subscription engineering.
The real design is everything around it: when work becomes durable, who may claim it, what happens when a worker dies, how retries are recognized, whether a callback can reach private infrastructure, whether revoked authority still leaks through a queue, and what “offline” means for a socket.
Ilavrita's current subscription implementation is still part of a developer preview, and the WebSocket path has an intentionally narrower durability model than rest-hook.
But that narrowness is explicit.
That is the direction we want for the rest of the platform too: fewer magical guarantees, more semantics an engineer can inspect and test.
For the current product boundary, see the Ilavrita docs, known limitations, and the roadmap.
