Webhooks
Outbound webhooks let Huli push a notification to your endpoint whenever a resource changes — a new Appointment, a finalized Encounter, a lab Observation — instead of you polling the FHIR API. They are configured entirely through the standard FHIR R4 Subscription resource: there is no Huli-native webhook object to learn.
The model is deliberately small. You register a Subscription that names a resource type and an HTTPS endpoint; Huli signs and POSTs a compact FHIR Bundle to that endpoint on every matching event; your receiver verifies the signature, dedupes on an event id, and GETs the full resource. Deliveries are at-least-once, retried on failure, and recoverable after an outage via $replay.
Creating a subscription
/fhir/R4/SubscriptionThe request body is a FHIR Subscription. A minimal one:
{
"resourceType": "Subscription",
"status": "requested",
"reason": "Sync finalized encounters into our EHR",
"criteria": "Encounter",
"channel": {
"type": "rest-hook",
"endpoint": "https://hooks.example.com/huli",
"payload": "application/fhir+json",
"header": ["X-Source-System: clinica-san-rafael"]
}
}
| Field | Required | Notes |
|---|---|---|
status | yes | Send requested; the server activates it and returns active. |
reason | yes | Non-empty free text describing the subscription. Not persisted — for your own audit trail on the call. |
criteria | yes | The resource type to notify on, e.g. "Encounter". Resource-type only in v2 (see below). |
channel.type | yes | Must be "rest-hook". No other channel type is supported. |
channel.endpoint | yes | HTTPS-only target URL. SSRF-checked at dial time — private, link-local, and metadata IPs are refused. |
channel.payload | yes | Must be "application/fhir+json". |
channel.header | no | Extra HTTP headers to send on every delivery, each as a "Name: Value" string. |
The signing secret is returned exactly once
A successful create returns 201 Created with the stored Subscriptionplus the signing secret in an extension, and a Cache-Control: no-store response header. The secret is shown this one time and is never returned again by any subsequent read.
The response also carries a non-blocking Business Associate Agreement (BAA) reminder as a contained OperationOutcome with severity: information. It does not affect the 201 — it is informational only.
Credential requirements
Creating a subscription requires a machine (API key) credential carrying the
system/Subscription scope family, and that credential must have been mintedwith a BAA attestation. Interactive user tokens cannot create subscriptions.
You can only subscribe to what you can read: creating a subscription (or retargeting its criteria with a PUT) also requires a read grant on the criteria resource type — for example, criteria: "Encounter" needs system/Encounter.rs on the same credential. A create or update without it is refused with 403 HPB-00104.
Lifecycle
A subscription moves through four states:
status | Meaning |
|---|---|
requested | The state you POST. The server transitions it to active. |
active | Deliveries flow. This is the only state that receives events (and the only state that replays). |
error | Auto-paused by the circuit breaker after repeated delivery failures. Reactivate with a PUT. |
off | Revoked (deleted). No further deliveries. |
Manage the subscription with the standard FHIR interactions:
/fhir/R4/Subscription/:id/fhir/R4/Subscription/:id/fhir/R4/Subscription/:id/fhir/R4/Subscription?status=activeTo resume an auto-paused subscription, PUT it back with status: "active" once your endpoint is healthy again.
Scopes
| Scope | Grants |
|---|---|
| system/Subscription.crud | Create, update, and delete subscriptions. |
| system/Subscription.rs | Read and search subscriptions, plus the $stats and $deliveries operations. |
Replay ($replay) mutates delivery state and requires system/Subscription.crud.
Create and update additionally require a read scope (.rs) on the criteria resource type — see "Credential requirements" above.
Events
In v2, lifecycle events are emitted for these resource types:
| Resource | Events |
|---|---|
| Appointment | created, updated, cancelled |
| Encounter | created, updated, finalized |
| Patient | created, updated |
| Observation | created |
| MedicationRequest | created, updated, cancelled |
| ServiceRequest | created, updated, completed |
The event-type string combines the resource and the transition, e.g. Encounter.finished. It arrives on every delivery in the X-Huli-Event-Type header, so you can route or filter without parsing the body.
The delivery payload
Each delivery is an HTTP POST of a FHIR Bundle of type: "history" with a single entry. The entry's request.method encodes the change:
POST— the resource was createdPUT— the resource was updatedDELETE— the resource was deleted or cancelled
Deliveries are id-level notifications, not full snapshots. A create/update carries only a minimal stub — { "resourceType": ..., "id": ... } — and a delete carries only the reference. Your receiver then GETs the full, current resource from the FHIR API. This keeps payloads small and avoids shipping stale copies of PHI.
{
"resourceType": "Bundle",
"type": "history",
"entry": [
{
"resource": { "resourceType": "Encounter", "id": "01965e2a-8c4d-7000-9001-000000000042" },
"request": { "method": "PUT", "url": "Encounter/01965e2a-8c4d-7000-9001-000000000042" }
}
]
}
Headers on every delivery
Every delivery POST carries these headers:
| Header | Value |
|---|---|
X-Huli-Signature | sha256= + lowercase hex of HMAC-SHA256(signing_secret, raw_request_body). |
X-Huli-Event-Id | Stable per event across retries and replays. Dedupe on this. |
X-Huli-Delivery-Id | Fresh per attempt — a retry or replay gets a new one. Use it to correlate one attempt. |
X-Huli-Event-Type | The event type, e.g. Encounter.finished. |
X-Huli-Occurred-At | RFC3339 timestamp of the source event. |
X-Huli-Replay | true only on replay deliveries. Absent otherwise. |
Content-Type | application/fhir+json. |
Verifying the signature
Compute HMAC-SHA256(signing_secret, rawBody), hex-encode it (lowercase), prefix sha256=, and constant-time-compare the result against the X-Huli-Signature header.
import crypto from 'node:crypto';
// `rawBody` MUST be the exact bytes received (a Buffer/string), not a re-serialized object.
function verifyHuliSignature(rawBody, signatureHeader, secret) {
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
def verify_huli_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
# raw_body MUST be the exact bytes received, not a re-serialized dict.
digest = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
expected = f"sha256={digest}"
return hmac.compare_digest(expected, signature_header or "")
Reject any delivery whose signature does not verify — respond with a 4xx and drop it.
Delivery semantics
- At-least-once. The same event may arrive more than once. Dedupe on
X-Huli-Event-Id, which is stable across retries and replays. - HTTPS only. Endpoints must be
https://. The target is SSRF-checked at dial time. - Success is
2xx. Any2xxresponse marks the delivery delivered. - Retries with backoff.
5xxresponses and network errors are retried with exponential backoff, up to 5 attempts. - Dead-letter. A delivery that exhausts its attempts moves to a dead-letter state (visible in
$deliveriesand counted in$stats). - Auto-pause. After 100 consecutive failures the subscription is auto-paused (
status: error). Reactivate it with aPUTsettingstatusback toactive. - Rate-limited per subscription. Deliveries to a single subscription are throttled so a large backlog cannot flood your endpoint.
- Redirects are refused. A
3xxresponse is not followed; it counts as a failure.
Replay — recovering from an outage
If your endpoint was down, re-enqueue the events you missed instead of losing them.
/fhir/R4/Subscription/:id/$replayThe body is a FHIR Parameters with a from (valueInstant, required) and an optional to (valueInstant, defaults to now). You may also pass from/to as RFC3339 query parameters.
{
"resourceType": "Parameters",
"parameter": [
{ "name": "from", "valueInstant": "2026-07-01T00:00:00Z" },
{ "name": "to", "valueInstant": "2026-07-02T00:00:00Z" }
]
}
Replay re-scans your organization's event history for the subscription's criteria (resource type) over the window and re-enqueues each matching event. This includes events that occurred before the subscription was created and events that were already delivered — replay is a re-scan of the outbox by criteria + window, so treat it as a backfill, not only a "redeliver what I missed" operation. Your event-id dedup (below) absorbs any overlap. Constraints:
- The window is clamped to the 30-day retention horizon — events older than that are gone.
- A single call re-enqueues at most 500 events. For a larger backlog, just call again — the same window is fine: events with an in-flight replay are skipped, so each call advances to the next-older events. Repeat until
truncatedcomes backfalse. - Only an
activesubscription may replay. - Repeated calls over the same window are safe: an event that already has an in-flight (undelivered) replay for this subscription is skipped, so you won't pile duplicates.
Replayed deliveries carry X-Huli-Replay: true and the original X-Huli-Event-Id, so your existing dedupe logic transparently absorbs any overlap with events that did get through. The call returns a Parameters summary:
{
"resourceType": "Parameters",
"parameter": [
{ "name": "deliveriesQueued", "valueInteger": 87 },
{ "name": "truncated", "valueBoolean": false }
]
}
Observability
Metrics — $stats
/fhir/R4/Subscription/:id/$statsReturns a Parameters with aggregate delivery health:
| Parameter | Meaning |
|---|---|
delivered | Deliveries that succeeded. |
failed | Deliveries that failed (all attempts counted). |
pending | Deliveries queued but not yet terminal. |
dead | Deliveries that exhausted retries (dead-lettered). |
deadLetterDepth | Current depth of the dead-letter backlog. |
totalAttempts | Total delivery attempts, including retries. |
successRate | Fraction of deliveries that succeeded. |
latencyP50Seconds | Median delivery latency, seconds. |
latencyP95Seconds | 95th-percentile delivery latency, seconds. |
Delivery trail — $deliveries
/fhir/R4/Subscription/:id/$deliveries?_count=50&status=failedReturns a Parameters with one delivery group per recent attempt. Each group carries: id, status, attempts, replay, event, resourceType, eventType, occurredAt, queuedAt, and — when set — lastStatusCode and deliveredAt.