---
title: Webhooks
description: Outbound webhooks via the FHIR R4 Subscription resource — create a rest-hook subscription, verify HMAC-signed deliveries, and recover missed events with replay.
nav: Concepts / Webhooks
order: 4
version: v1
source: handwritten
updated: 2026-07-02
---

# 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

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

The request body is a FHIR `Subscription`. A minimal one:

```json
{
  "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.                        |

<Callout variant="warning">
`criteria` is the resource type only. A FHIR query string like `Encounter?status=finished`
is **rejected** in v2 — filter on the event type in your receiver instead (every delivery
carries an `X-Huli-Event-Type` header).
</Callout>

### The signing secret is returned exactly once

A successful create returns <StatusBadge code="201" /> with the stored `Subscription`
**plus 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.

<Callout variant="danger">
Capture the signing secret from the `201` response immediately and store it in your
secrets manager. There is no endpoint that re-reveals it — losing it means deleting the
subscription and creating a new one.
</Callout>

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
<Scope>system/Subscription</Scope> 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 <Scope>system/Encounter.rs</Scope> 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:

<Endpoint method="GET" path="/fhir/R4/Subscription/:id" />
<Endpoint method="PUT" path="/fhir/R4/Subscription/:id" />
<Endpoint method="DELETE" path="/fhir/R4/Subscription/:id" />
<Endpoint method="GET" path="/fhir/R4/Subscription?status=active" />

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

## Scopes

| Scope                                   | Grants                                                                         |
| --------------------------------------- | ------------------------------------------------------------------------------ |
| <Scope>system/Subscription.crud</Scope> | Create, update, and delete subscriptions.                                      |
| <Scope>system/Subscription.rs</Scope>   | Read and search subscriptions, plus the `$stats` and `$deliveries` operations. |

Replay (`$replay`) mutates delivery state and requires <Scope>system/Subscription.crud</Scope>.

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

```json
{
  "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`.                                                               |

<Callout variant="note">
Huli-owned headers always win. If a `channel.header[]` entry collides with any of the
`X-Huli-*` headers or `Content-Type`, the Huli value is sent — your custom header is only
honoured for names Huli does not set.
</Callout>

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

<Callout variant="danger">
Verify against the **raw received body bytes**. Do not parse and re-serialize the JSON
first — any whitespace or key-ordering change alters the bytes and the HMAC will not match.
Read the body as raw bytes before your JSON framework touches it.
</Callout>

:::CodeGroup

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

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

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

```json
{
  "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:

```json
{
  "resourceType": "Parameters",
  "parameter": [
    { "name": "deliveriesQueued", "valueInteger": 87 },
    { "name": "truncated", "valueBoolean": false }
  ]
}
```

## Observability

### Metrics — `$stats`

<Endpoint method="GET" path="/fhir/R4/Subscription/:id/$stats" />

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

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

<Callout variant="info">
`$deliveries` is a delivery **ledger**, not a payload store. It never exposes the delivery
body, your endpoint URL, or the signing secret — only the metadata needed to debug
delivery health.
</Callout>
