# Webhooks

URL: https://docs.healthcare.dev/webhooks

Webhooks push the [change feeds](/versioning#change-feed) to an https endpoint you run, so you
don't have to poll. They are part of the Pro and Team plans (`403` `plan-upgrade-required` below
Pro) and never count as billable calls. Manage them in the
[dashboard](https://app.healthcare.dev/webhooks) or with the API.

## Events

| Type               | When                                                                      | Body                                                       |
| ------------------ | ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| `dataset.released` | A dataset published a release                                             | The release summary from `/v1/datasets/{dataset}/releases` |
| `record.changed`   | A code, NDC, RxCUI or NPI you listed was added, changed or retired by one | The item from [`/v1/changes`](/versioning#change-feed)     |
| `coverage.changed` | A Medicare coverage document changed materially                           | The item from [`/v1/coverage/changes`](/coverage-changes)  |

Only real changes are pushed. A record is in a release's changes only when its data changed, and
coverage events are the material ones: formatting-only revisions of a coverage document are
recorded on our side and never sent. Every body has a stable `id` and its `type`:

```json
{
  "id": "chg_918273",
  "type": "record.changed",
  "dataset": "icd10cm",
  "key": "E119",
  "kind": "changed",
  "data_version": "fy2027",
  "published_at": "2026-10-01T06:12:44.120Z"
}
```

```json
{
  "id": "rel_ndc_2026-10-25",
  "type": "dataset.released",
  "dataset": "ndc",
  "version": "2026-10-25",
  "effective_date": "2026-10-25",
  "published_at": "2026-10-25T07:02:10.000Z",
  "source_published_at": "2026-10-25T04:00:00.000Z",
  "baseline": false,
  "records": 115204,
  "added": 41,
  "changed": 212,
  "retired": 9,
  "changes": "/v1/changes?dataset=ndc&version=2026-10-25"
}
```

`coverage.changed` bodies are exactly the [coverage change feed](/coverage-changes) items, whose
`id` is the feed's.

## Subscribe

```sh
curl -X POST https://api.healthcare.dev/v1/webhooks \
  -H "Authorization: Bearer $HDEV_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/healthcare-dev",
    "events": ["dataset.released", "record.changed", "coverage.changed"],
    "datasets": ["icd10cm", "hcpcs"],
    "subjects": ["E11.9", "I10", "J1817", "11720"],
    "description": "Codes our claims engine uses"
  }'
```

The response includes `secret` (`whsec_...`). It is shown only once: store it in your secret
manager. We keep it encrypted and use it only to sign.

- `events`: any of the three types.
- `datasets`: dataset ids for `dataset.released` and `record.changed`; leave it out for every
  dataset.
- `subjects`: up to 1,000 codes, NDCs, RxCUIs or NPIs. `record.changed` needs them: a whole
  release can change tens of thousands of records, so for whole datasets subscribe to
  `dataset.released` and page through its `changes` link. Subjects are matched against record keys,
  so `E11.9`, `e11.9` and `E119` are the same, and a package NDC (`0002-1433-80`) means its
  product (`0002-1433`). For `coverage.changed`, subjects filter to the procedure codes in
  `affected_codes`; without subjects you get every coverage change.
- `url`: https only, on a public host. We refuse private, loopback, link-local and cloud metadata
  addresses, both when you subscribe and on every delivery (the name is resolved again each time).
- Up to 20 webhooks per account.

`GET /v1/webhooks` lists them (never with the secret), `PATCH /v1/webhooks/{id}` with
`{"status": "disabled"}` or `{"status": "active"}` turns one off or on, and
`DELETE /v1/webhooks/{id}` removes it. The SDK has `client.webhooks.create`, `list`, `get`,
`setStatus`, `delete`, `deliveries` and `replay` (in Python, `set_status`).

## Deliveries

Each delivery is a `POST` with a JSON body and these headers:

| Header                   | Value                                                                        |
| ------------------------ | ---------------------------------------------------------------------------- |
| `Hdev-Webhook-Id`        | The event `id`. The same on every retry and replay: deduplicate by it        |
| `Hdev-Webhook-Timestamp` | Unix seconds when this attempt was signed                                    |
| `Hdev-Webhook-Signature` | `v1=` and the hex HMAC-SHA256 of `<timestamp>.<body>`, keyed with the secret |

Answer with any `2xx` within 10 seconds; do the work after answering. Anything else (a timeout,
an error, a redirect: we don't follow them) is retried 7 more times, after 1 minute, 5 minutes,
30 minutes, 2 hours, 5 hours, 8 hours and 8 hours: about a day in all. A webhook whose last 15
attempts all failed, over more than a day, is turned off (`status: disabled`,
`disabled_reason: failing`) and the account's email is told. A failure more than a day after the
previous one starts a new count, so an occasional failure never adds up to a disable.

While a webhook is off (turned off for failing, or by you), its pending deliveries and every new
event for it are `held`, not sent. Turning it back on sends them all (within the 30 days the log
keeps). Turning a webhook on needs your plan to still include its events.

Deliveries can arrive more than once and out of order. Use `id` to skip repeats, and
`published_at` or `detected_at` to order.

The delivery log (`GET /v1/webhooks/{id}/deliveries`, and the dashboard) keeps 30 days: each
delivery's status (`pending` while retrying, `held` while the webhook is off, `succeeded`,
`failed`, `skipped` when your plan no longer includes it), and every attempt with its status code,
latency and the first 1 KB of your response. Page with `cursor` from `links.next`.
`POST /v1/webhooks/{id}/deliveries/{delivery}/replay` sends one again now: one queued replay at a
time per delivery, and at most 10.

Datasets whose license keeps them out of the API also produce no webhook events, and can't be
named in `datasets`.

## Verify the signature

Always verify before trusting a body: compute the HMAC over the raw body exactly as received
(before any JSON parsing), compare it in constant time, and reject timestamps more than five
minutes from your clock. The key is the whole secret string, `whsec_` included.

**TypeScript, with the SDK** (Node 18+, Deno, Bun, edge runtimes):

```ts
import { verifyWebhook, WebhookVerificationError } from "@healthcare.dev/sdk";

export async function POST(req: Request) {
  const body = await req.text(); // the raw body, not re-serialized JSON
  try {
    const event = await verifyWebhook(process.env.HDEV_WEBHOOK_SECRET!, req.headers, body);
    if (await alreadyProcessed(event.id)) return new Response(null, { status: 204 });
    switch (event.type) {
      case "record.changed":
        await queue.add("refresh-record", { dataset: event.dataset, key: event.key });
        break;
      case "dataset.released":
      case "coverage.changed":
        await queue.add(event.type, event);
    }
    return new Response(null, { status: 204 });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return new Response(null, { status: 400 });
    throw err;
  }
}
```

**TypeScript, without the SDK** (Node):

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(secret: string, headers: Headers, body: string): boolean {
  const ts = headers.get("hdev-webhook-timestamp") ?? "";
  const sent = headers.get("hdev-webhook-signature") ?? "";
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${ts}.${body}`).digest();
  return sent.split(" ").some((s) => {
    const sig = Buffer.from(s.replace(/^v1=/, ""), "hex");
    return s.startsWith("v1=") && sig.length === expected.length && timingSafeEqual(sig, expected);
  });
}
```

**Python, with the SDK** ([Python SDK](/guides/python)):

```python
from healthcare_dev import WebhookVerificationError, verify_webhook

# Flask
@app.post("/hooks/healthcare-dev")
def hook():
    try:
        event = verify_webhook(SECRET, request.headers, request.get_data())
    except WebhookVerificationError:
        return "", 400
    queue.add(event["type"], event)  # deduplicate by event["id"]
    return "", 204
```

**Python, without the SDK:**

```python
import hashlib, hmac, json, time

def verify_webhook(secret: str, headers: dict, body: bytes) -> dict:
    ts = headers["Hdev-Webhook-Timestamp"]
    sent = headers["Hdev-Webhook-Signature"].split()
    if abs(time.time() - int(ts)) > 300:
        raise ValueError("stale timestamp")
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
    if not any(hmac.compare_digest(s, f"v1={expected}") for s in sent):
        raise ValueError("bad signature")
    return json.loads(body)

# Flask: verify_webhook(SECRET, request.headers, request.get_data())
```

**curl and openssl**, to check a captured delivery by hand:

```sh
SECRET='whsec_...'
TS='1767225600'                 # Hdev-Webhook-Timestamp
BODY='{"id":"chg_42","type":"record.changed"}'
printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= /v1=/'
# compare with Hdev-Webhook-Signature
```

A test vector: secret `whsec_dGVzdC1zZWNyZXQtZm9yLXRoZS1kb2NzLXZlY3Rvcg`, timestamp `1767225600`
and body `{"id":"chg_42","type":"record.changed"}` sign to
`v1=f7dab3be3d59f0ac1fef8fd1d438d24697b425e5a3d17fcf872ba02deb65e4c9`.

`Hdev-Webhook-Signature` may list several space-separated signatures (for example while a secret
is rotated); accept the request when any one matches.
