---
title: Receive webhooks
description: React to appointments and clinical events as they happen — register a rest-hook Subscription, verify the HMAC signature on every delivery, dedupe on the event id, and recover missed events with $replay.
nav: Recipes
order: 90
version: v1
source: handwritten
updated: 2026-08-13
---

# 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](/v1/concepts/webhooks) 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 <Scope>system/Subscription.crud</Scope> 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 <Scope>system/Subscription.crud</Scope> (create) and, for the
  observability calls, <Scope>system/Subscription.rs</Scope>.
- A read grant on the resource type you subscribe to — this recipe's `criteria: "Encounter"`
  needs <Scope>system/Encounter.rs</Scope> 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

<Endpoint method="POST" path="/fhir/R4/Subscription" />

Notify on finalized (and other) `Encounter` events. `criteria` is the resource **type**
only — filter on the event type in your receiver.

:::CodeGroup

```bash
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"
    }
  }'
```

```python
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 <StatusBadge code="201" /> 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[]`.

<Callout variant="danger">
The signing secret is returned **exactly once**. Read it out of this `201` and store it in
your secrets manager now — no endpoint re-reveals it. If you lose it, delete the
subscription and create a new one.
</Callout>

### 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`.

<Callout variant="danger">
Capture the raw request body **before** any JSON middleware parses it. Re-serializing the
JSON changes the bytes and the signature will never match. In Express, use
`express.raw()`; in Flask, read `request.get_data()`.
</Callout>

:::CodeGroup

```javascript
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);
```

```python
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
```

:::

<Callout variant="note">
Dedupe on `X-Huli-Event-Id`, not `X-Huli-Delivery-Id`. The event id is stable across
retries and replays; the delivery id is fresh on every attempt. Deliveries are
at-least-once, so a durable dedupe store (not the in-memory `Set` above) is required in
production.
</Callout>

### 3. Return the right status

- Return any <StatusBadge code="200" /> (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.

<Callout variant="warning">
After 100 consecutive failures the subscription is auto-paused to `status: error` and stops
receiving events. Once your endpoint is healthy, resume it with a `PUT` setting
`status: "active"`, then [replay](#4-replay-missed-events-after-an-outage) the gap.
</Callout>

### 4. Replay missed events after an outage

<Endpoint method="POST" path="/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.

```bash
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:

```json
{
  "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 <Scope>system/Subscription.rs</Scope>):

```bash
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

<StatusBadge code="400" /> `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.

<StatusBadge code="403" /> `HPB-00104` — the token lacks
<Scope>system/Subscription.crud</Scope>, 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](/v1/concepts/webhooks) — the full concept: lifecycle, event catalogue, headers,
  and delivery semantics.
- [Rate limiting](/v1/concepts/rate-limiting) — per-subscription delivery throttling protects
  your endpoint from backlog floods.
