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

POST/fhir/R4/Subscription

The 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"]
  }
}
FieldRequiredNotes
statusyesSend requested; the server activates it and returns active.
reasonyesNon-empty free text describing the subscription. Not persisted — for your own audit trail on the call.
criteriayesThe resource type to notify on, e.g. "Encounter". Resource-type only in v2 (see below).
channel.typeyesMust be "rest-hook". No other channel type is supported.
channel.endpointyesHTTPS-only target URL. SSRF-checked at dial time — private, link-local, and metadata IPs are refused.
channel.payloadyesMust be "application/fhir+json".
channel.headernoExtra 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 minted

with 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:

statusMeaning
requestedThe state you POST. The server transitions it to active.
activeDeliveries flow. This is the only state that receives events (and the only state that replays).
errorAuto-paused by the circuit breaker after repeated delivery failures. Reactivate with a PUT.
offRevoked (deleted). No further deliveries.

Manage the subscription with the standard FHIR interactions:

GET/fhir/R4/Subscription/:id
PUT/fhir/R4/Subscription/:id
DELETE/fhir/R4/Subscription/:id
GET/fhir/R4/Subscription?status=active

To resume an auto-paused subscription, PUT it back with status: "active" once your endpoint is healthy again.

Scopes

ScopeGrants
system/Subscription.crudCreate, update, and delete subscriptions.
system/Subscription.rsRead 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:

ResourceEvents
Appointmentcreated, updated, cancelled
Encountercreated, updated, finalized
Patientcreated, updated
Observationcreated
MedicationRequestcreated, updated, cancelled
ServiceRequestcreated, 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 created
  • PUT — the resource was updated
  • DELETE — 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:

HeaderValue
X-Huli-Signaturesha256= + lowercase hex of HMAC-SHA256(signing_secret, raw_request_body).
X-Huli-Event-IdStable per event across retries and replays. Dedupe on this.
X-Huli-Delivery-IdFresh per attempt — a retry or replay gets a new one. Use it to correlate one attempt.
X-Huli-Event-TypeThe event type, e.g. Encounter.finished.
X-Huli-Occurred-AtRFC3339 timestamp of the source event.
X-Huli-Replaytrue only on replay deliveries. Absent otherwise.
Content-Typeapplication/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. Any 2xx response marks the delivery delivered.
  • Retries with backoff. 5xx responses 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 $deliveries and counted in $stats).
  • Auto-pause. After 100 consecutive failures the subscription is auto-paused (status: error). Reactivate it with a PUT setting status back to active.
  • Rate-limited per subscription. Deliveries to a single subscription are throttled so a large backlog cannot flood your endpoint.
  • Redirects are refused. A 3xx response 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.

POST/fhir/R4/Subscription/:id/$replay

The 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 truncated comes back false.
  • Only an active subscription 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

GET/fhir/R4/Subscription/:id/$stats

Returns a Parameters with aggregate delivery health:

ParameterMeaning
deliveredDeliveries that succeeded.
failedDeliveries that failed (all attempts counted).
pendingDeliveries queued but not yet terminal.
deadDeliveries that exhausted retries (dead-lettered).
deadLetterDepthCurrent depth of the dead-letter backlog.
totalAttemptsTotal delivery attempts, including retries.
successRateFraction of deliveries that succeeded.
latencyP50SecondsMedian delivery latency, seconds.
latencyP95Seconds95th-percentile delivery latency, seconds.

Delivery trail — $deliveries

GET/fhir/R4/Subscription/:id/$deliveries?_count=50&status=failed

Returns 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.