Part 6 of 7 — Roster API Series: migrating member-file ingestion from CSV over SFTP to a FHIR API.
The SFTP flow is polling twice over: the producer drops a file whenever, the consumer’s cron picks it up whenever. Worst case, a change waits a full day on each side, and nobody can tell a “nothing changed” day from a “the export broke” day.
With the API from Parts 3–5 the consumer could poll _since every hour. Better: the producer tells the consumer when the list changes. ATR defines exactly that, using the FHIR R5 subscription model backported to R4.
The moving parts
| Piece | Value in ATR |
|---|---|
| Topic | http://hl7.org/fhir/us/davinci-atr/SubscriptionTopic/atr-list-change |
| Focus resource | Group |
| Filter | the specific Group/[id] the consumer cares about |
| Channel | rest-hook (producers must support it, consumers must implement it) |
| Payload | application/fhir+json, id-only |
| Lifecycle | consumer sets active; sets off when the list is no longer used |
Id-only is a deliberate choice. The notification says “Group/roster-2026 changed” and nothing else — no PHI in the webhook body. The consumer then fetches what it needs through the authenticated API. The webhook is a doorbell, not a delivery.
Creating the subscription (consumer → producer)
In R4, the backport puts R5 concepts into extensions:
{
"resourceType": "Subscription",
"meta": {
"profile": ["http://hl7.org/fhir/us/davinci-atr/StructureDefinition/atr-list-change-subscription"]
},
"status": "requested",
"reason": "Notify consumer when attribution list roster-2026 changes",
"criteria": "http://hl7.org/fhir/us/davinci-atr/SubscriptionTopic/atr-list-change",
"_criteria": {
"extension": [{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-filter-criteria",
"valueString": "Group?_id=roster-2026"
}]
},
"channel": {
"extension": [
{"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-heartbeat-period",
"valueUnsignedInt": 3600},
{"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-timeout",
"valueUnsignedInt": 30}
],
"type": "rest-hook",
"endpoint": "https://consumer.example.org/hooks/atr",
"payload": "application/fhir+json",
"_payload": {
"extension": [{
"url": "http://hl7.org/fhir/uv/subscriptions-backport/StructureDefinition/backport-payload-content",
"valueCode": "id-only"
}]
},
"header": ["Authorization: Bearer <shared-secret-for-this-hook>"]
}
}
POST it to /Subscription with the SMART token from Part 5. The producer verifies the client may access Group/roster-2026 (ATR requires the same instance-level check as reads, and a 401 for unauthorized create/update) and moves the subscription to active.
The heartbeat-period is underrated: if the consumer stops receiving heartbeats, the channel is broken. That’s the signal SFTP never gave — “silence” now means something.
What a notification looks like
In the R4 backport, a notification is a Bundle of type history. The first entry is a Parameters resource describing the subscription status; for id-only, the following entries carry only fullUrls of the changed resources.
{
"resourceType": "Bundle",
"type": "history",
"timestamp": "2026-10-30T14:02:11Z",
"entry": [
{
"fullUrl": "urn:uuid:9e3c...",
"resource": {
"resourceType": "Parameters",
"parameter": [
{"name": "subscription", "valueReference": {"reference": "https://roster.example.org/fhir/Subscription/sub-42"}},
{"name": "topic", "valueCanonical": "http://hl7.org/fhir/us/davinci-atr/SubscriptionTopic/atr-list-change"},
{"name": "status", "valueCode": "active"},
{"name": "type", "valueCode": "event-notification"},
{"name": "events-since-subscription-start", "valueString": "17"},
{"name": "notification-event", "part": [
{"name": "event-number", "valueString": "17"},
{"name": "timestamp", "valueInstant": "2026-10-30T14:02:10Z"},
{"name": "focus", "valueReference": {"reference": "https://roster.example.org/fhir/Group/roster-2026"}}
]}
]
},
"request": {"method": "GET", "url": "https://roster.example.org/fhir/Subscription/sub-42/$status"},
"response": {"status": "200"}
},
{
"fullUrl": "https://roster.example.org/fhir/Group/roster-2026",
"request": {"method": "PUT", "url": "Group/roster-2026"},
"response": {"status": "200"}
}
]
}
type is one of handshake, heartbeat or event-notification. Only the last means data changed.
Consumer: the webhook receiver
# hooks.py
import hmac
import os
from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request
from client import export_group # Part 4
from smart_client import get_token # Part 5
app = FastAPI()
HOOK_SECRET = os.environ["ATR_HOOK_SECRET"]
STATE = {"last_event": 0, "last_sync": None}
def param(params: dict, name: str):
for p in params["parameter"]:
if p["name"] == name:
return p
return None
def sync(group_id: str):
data = export_group(group_id, get_token(), since=STATE["last_sync"])
# load data["Group"], data["Patient"], data["Coverage"] into staging...
STATE["last_sync"] = data["Group"][0]["meta"]["lastUpdated"]
@app.post("/hooks/atr")
async def atr_hook(request: Request, background: BackgroundTasks,
authorization: str = Header(...)):
if not hmac.compare_digest(authorization, f"Bearer {HOOK_SECRET}"):
raise HTTPException(401)
bundle = await request.json()
status = bundle["entry"][0]["resource"]
kind = param(status, "type")["valueCode"]
events = int(param(status, "events-since-subscription-start")["valueString"])
if kind in ("handshake", "heartbeat"):
# Gap detection: heartbeats carry the running event count too.
if events > STATE["last_event"]:
background.add_task(sync, "roster-2026") # missed something, catch up
return {"ok": True}
focus = param(status, "notification-event")
group_ref = next(p for p in focus["part"] if p["name"] == "focus")["valueReference"]["reference"]
STATE["last_event"] = events
background.add_task(sync, group_ref.rsplit("/", 1)[-1])
return {"ok": True} # respond fast; do the work after
Design rules I follow for webhook receivers:
- Acknowledge fast, process later. Return 200 within the timeout; the export can take minutes.
- Treat notifications as hints, not data. Always re-read through the API. Duplicates and out-of-order deliveries become harmless because
syncis idempotent and_since-based. - Use
events-since-subscription-startfor gap detection. If a heartbeat reports 19 events and you’ve processed 17, you missed two deliveries — run a catch-up sync. - Keep a fallback poll. A daily
_sincesync costs nothing and covers a broken channel. Notifications reduce latency; they don’t replace reconciliation.
Producer: emitting the notification
On the server side, hook it into new_version() from Part 3: every new Group version increments the subscription’s event counter and enqueues a POST to each active subscriber whose filter matches that Group. Retry with backoff; after N failures set the subscription status to error so the consumer sees it on $status.
Next: Part 7 — Migration path: running SFTP and the API side by side and proving they agree before you turn the old one off.
References
- ATR — Subscription for list changes
- ATR — list change Subscription profile
- FHIR Subscriptions R5 Backport IG
- Backport IG — R4 notifications
- Backport IG — channels (rest-hook)
- Backport IG — payload content
- FHIR R4 Subscription
- FastAPI — Background tasks
30 Oct 2026 · #fhir #webhooks #fastapi #rosterapiseries
GLB_