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
| 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 |
coverage.changed | A Medicare coverage document changed materially | The 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 fordataset.releasedandrecord.changed; leave it out for every dataset.subjects: up to 1,000 codes, NDCs, RxCUIs or NPIs.record.changedneeds them: a whole release can change tens of thousands of records, so for whole datasets subscribe todataset.releasedand page through itschangeslink. Subjects are matched against record keys, soE11.9,e11.9andE119are the same, and a package NDC (0002-1433-80) means its product (0002-1433). Forcoverage.changed, subjects filter to the procedure codes inaffected_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):
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 "", 204Python, 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-SignatureA 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.
Joined answers
Everything about an NDC or an NPI in one call: RxNorm, J-codes, NADAC, label, shortage and recall flags for a drug; taxonomy, location, Medicare enrollment, opt-out and OIG exclusions for a provider.
Child keys
Keys with their own scopes, rate limit and monthly spend cap, billed to your account (Pro and Team).