Receive webhooks
React to appointments and clinical events as they happen, without polling. Stand up an endpoint that receives Huli's outbound webhooks: register a FHIR R4 Subscription, verify the HMAC signature on each delivery, dedupe on the stable event id, and pull the full resource from the FHIR API. By the end you have a receiver that is safe against forged, duplicated, and out-of-order deliveries.
For the full model — lifecycle, delivery semantics, and every header — read the Webhooks concept first. This recipe is the working end-to-end path.
Audience
You run an integration that needs to react to appointments and clinical events (a new or rescheduled appointment, a finalized encounter) without polling. You can host an HTTPS endpoint and you hold a machine (API key) credential minted with a BAA (Business Associate Agreement) attestation and the system/Subscription.crud scope.
You'll need
- A publicly reachable HTTPS endpoint. Private, link-local, and metadata IPs are refused at dial time.
- A machine bearer token with system/Subscription.crud (create) and, for the observability calls, system/Subscription.rs.
- A read grant on the resource type you subscribe to — this recipe's
criteria: "Encounter"needs system/Encounter.rs on the same credential ("subscribe only to what you can read"; otherwise the create is refused with403 HPB-00104). - A place to store the signing secret returned once at create time.
End state
A live Subscription in status: active, an endpoint that verifies X-Huli-Signature and dedupes on X-Huli-Event-Id, and a tested $replay path for catching up after downtime.
Steps
1. Create the subscription
/fhir/R4/SubscriptionNotify on finalized (and other) Encounter events. criteria is the resource type only — filter on the event type in your receiver.
curl -X POST "https://api.huli.ai/fhir/R4/Subscription" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Subscription",
"status": "requested",
"reason": "Sync encounters into our EHR",
"criteria": "Encounter",
"channel": {
"type": "rest-hook",
"endpoint": "https://hooks.example.com/huli",
"payload": "application/fhir+json"
}
}'
import os
import requests
resp = requests.post(
"https://api.huli.ai/fhir/R4/Subscription",
headers={
"Authorization": f"Bearer {os.environ['HULI_API_KEY']}",
"Content-Type": "application/fhir+json",
},
json={
"resourceType": "Subscription",
"status": "requested",
"reason": "Sync encounters into our EHR",
"criteria": "Encounter",
"channel": {
"type": "rest-hook",
"endpoint": "https://hooks.example.com/huli",
"payload": "application/fhir+json",
},
},
timeout=30,
)
print(resp.status_code) # 201
The response is 201 Created with Cache-Control: no-store. Its body is the stored Subscription (now status: active) with the signing secret in an extension and a non-blocking BAA reminder in contained[].
2. Verify the signature on every delivery
Each delivery is a POST of a Bundle (type: "history") with these headers: X-Huli-Signature, X-Huli-Event-Id, X-Huli-Delivery-Id, X-Huli-Event-Type, X-Huli-Occurred-At, and — on replays — X-Huli-Replay: true.
Recompute the HMAC over the raw body bytes and constant-time-compare against X-Huli-Signature.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
const SECRET = process.env.HULI_WEBHOOK_SECRET;
const seen = new Set(); // swap for a durable, persistent store in production
// Raw body — do NOT use express.json() on this route.
app.post('/huli', express.raw({ type: '*/*' }), (req, res) => {
const sig = req.get('X-Huli-Signature') ?? '';
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(sig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).end();
}
const eventId = req.get('X-Huli-Event-Id');
if (seen.has(eventId)) return res.status(200).end(); // duplicate — ack and skip
seen.add(eventId);
const bundle = JSON.parse(req.body.toString('utf8'));
const entry = bundle.entry[0];
// entry.request.method: POST=created, PUT=updated, DELETE=deleted/cancelled.
// Id-level notification — GET the full resource from the FHIR API here.
console.log(req.get('X-Huli-Event-Type'), entry.request.method, entry.request.url);
res.status(200).end(); // 2xx = delivered
});
app.listen(8080);
import hashlib
import hmac
import os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ["HULI_WEBHOOK_SECRET"].encode()
seen = set() # swap for a durable, persistent store in production
@app.post("/huli")
def huli():
raw = request.get_data() # raw bytes, before JSON parsing
digest = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
if not hmac.compare_digest(expected, request.headers.get("X-Huli-Signature", "")):
return "", 401
event_id = request.headers.get("X-Huli-Event-Id")
if event_id in seen: # duplicate — ack and skip
return "", 200
seen.add(event_id)
bundle = request.get_json()
entry = bundle["entry"][0]
# entry["request"]["method"]: POST=created, PUT=updated, DELETE=deleted/cancelled.
# Id-level notification — GET the full resource from the FHIR API here.
print(request.headers.get("X-Huli-Event-Type"), entry["request"]["method"])
return "", 200 # 2xx = delivered
3. Return the right status
- Return any 200 OK (or other
2xx) once you have durably accepted the event. That marks the delivery delivered. - Return a
4xxfor a signature that does not verify — the delivery is dropped. - If your handler throws or times out, let it surface as a
5xx: Huli retries with exponential backoff, up to 5 attempts, then dead-letters. - Do not answer with a redirect. A
3xxis refused and counts as a failure.
4. Replay missed events after an outage
/fhir/R4/Subscription/:id/$replayRe-enqueue events whose occurred_at falls in a time window. Only an active subscription may replay; the window is clamped to the 30-day retention horizon and one call re-enqueues at most 500 events.
curl -X POST "https://api.huli.ai/fhir/R4/Subscription/$SUB_ID/\$replay" \
-H "Authorization: Bearer $HULI_API_KEY" \
-H "Content-Type: application/fhir+json" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "from", "valueInstant": "2026-07-01T00:00:00Z" },
{ "name": "to", "valueInstant": "2026-07-02T00:00:00Z" }
]
}'
The response summarizes the run:
{
"resourceType": "Parameters",
"parameter": [
{ "name": "deliveriesQueued", "valueInteger": 142 },
{ "name": "truncated", "valueBoolean": false }
]
}
Replayed deliveries carry X-Huli-Replay: true and the original X-Huli-Event-Id, so the dedupe from step 2 transparently absorbs any overlap. If truncated is true, call again with the same window — events with an in-flight replay are skipped, so each call advances to older events. Repeat until truncated is false.
5. Watch delivery health
Read aggregate health with $stats and the recent per-attempt trail with $deliveries (both need system/Subscription.rs):
curl "https://api.huli.ai/fhir/R4/Subscription/$SUB_ID/\$stats" \
-H "Authorization: Bearer $HULI_API_KEY"
curl "https://api.huli.ai/fhir/R4/Subscription/$SUB_ID/\$deliveries?_count=50&status=failed" \
-H "Authorization: Bearer $HULI_API_KEY"
$stats reports delivered, failed, pending, dead, deadLetterDepth, totalAttempts, successRate, and delivery-latency percentiles. $deliveries returns one group per recent attempt (id, status, attempts, replay, eventType, occurredAt, lastStatusCode, …) and never exposes payloads, endpoints, or secrets.
What can go wrong
400 Bad RequestHPB-00101 on create — a criteria with a query string(Encounter?status=finished), a channel.type other than rest-hook, a non-HTTPS endpoint, or a channel.payload that is not application/fhir+json. Send a bare resource type and the required channel fields.
HPB-00104 — the token lackssystem/Subscription.crud, or was not minted with a BAA attestation. Re-mintthe machine credential with the subscription scope and BAA.
Signature mismatches in step 2 are almost always a re-serialized body: verify against the raw received bytes, not a parsed-and-re-encoded object.
Related
- Webhooks — the full concept: lifecycle, event catalogue, headers, and delivery semantics.
- Rate limiting — per-subscription delivery throttling protects your endpoint from backlog floods.