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

POST/fhir/R4/Subscription

Notify 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 4xx for 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 3xx is refused and counts as a failure.

4. Replay missed events after an outage

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

Re-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 Request HPB-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.

403 Forbidden HPB-00104 — the token lackssystem/Subscription.crud, or was not minted with a BAA attestation. Re-mint

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