healthcare.dev docs

Webhooks

Get dataset releases, changes to the codes, NDCs and NPIs you care about, and Medicare coverage changes pushed to your endpoint, signed.

Webhooks push the change feeds 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 or with the API.

Events

TypeWhenBody
dataset.releasedA dataset published a releaseThe release summary from /v1/datasets/{dataset}/releases
record.changedA code, NDC, RxCUI or NPI you listed was added, changed or retired by oneThe item from /v1/changes
coverage.changedA Medicare coverage document changed materiallyThe item from /v1/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:

{
  "id": "chg_918273",
  "type": "record.changed",
  "dataset": "icd10cm",
  "key": "E119",
  "kind": "changed",
  "data_version": "fy2027",
  "published_at": "2026-10-01T06:12:44.120Z"
}
{
  "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 items, whose id is the feed's.

Subscribe

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:

HeaderValue
Hdev-Webhook-IdThe event id. The same on every retry and replay: deduplicate by it
Hdev-Webhook-TimestampUnix seconds when this attempt was signed
Hdev-Webhook-Signaturev1= 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):

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

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

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:

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:

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.

On this page