Skip to content
>GLB_
Go back

Roster change notifications with FHIR Subscriptions (R4 backport)

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

PieceValue in ATR
Topichttp://hl7.org/fhir/us/davinci-atr/SubscriptionTopic/atr-list-change
Focus resourceGroup
Filterthe specific Group/[id] the consumer cares about
Channelrest-hook (producers must support it, consumers must implement it)
Payloadapplication/fhir+json, id-only
Lifecycleconsumer 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:

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


30 Oct 2026 · #fhir #webhooks #fastapi #rosterapiseries

GLB_


Share this post:

Related Posts


Previous Post
Migrating from SFTP to a roster API: dual-run and reconciliation
Next Post
From SSH keys to SMART Backend Services: auth for system-to-system FHIR