Guide
Receive webhook events
Be told when a record changes instead of polling for it.
Webhooks notify your server when something happens, so you don’t have to poll. You create an endpoint in the Hermes Health web app: a URL plus the named events it receives and the scope they cover. There is no API for managing endpoints yet.
Create an endpoint
- Sign in to Hermes Health and open Settings → API → Notifications.
- Select Add a notification and choose the Webhook channel.
- Enter your receiver’s URL. It must be publicly resolvable; private and loopback addresses are rejected.
- Choose the environment,
productionorsandbox. An endpoint receives events only from projects in its own environment. - Pick the events (next section): tick a whole group, or single events.
- Choose the scope: which projects, and whose records.
- Optionally, add up to 20 custom headers sent on every delivery, such as
an
Authorizationheader your receiver checks. - Copy the endpoint’s signing secret. You need it to verify deliveries.
Creating a webhook endpoint requires the
ManageWebhookspermission, because a webhook can send PHI to an external URL. You cannot override theContent-Type,HostorX-Webhook-*headers.
Point a sandbox endpoint at a test receiver while you build. Sandbox projects complete searches and requests on their own, so they exercise the whole flow.
Choose events
Events are named group.event. Ticking a group subscribes to every event it
has when you save.
| Group | Covers |
|---|---|
recordRequest | A record request is created, changes status, or reaches its follow-up date. |
siteSonar | Site Sonar facility discovery starts or finishes for a patient. |
patientHistories | Patient Histories clinical-history retrieval starts or finishes for a patient. |
ehrSearch | EHR Search starts or finishes for a patient. |
patient | A patient is created, their profile changes, or their overall search status changes. |
authorization | A patient’s authorization is reviewed or needs attention. |
clinicalData | A run adds clinical data the patient did not already have. Webhook endpoints only. |
note | A note is added or edited. |
aiResearch | An AI research run you started finishes. Sent only to your own endpoints. |
The events:
| Event | Fires when |
|---|---|
recordRequest.created | A record request is created. |
recordRequest.statusChanged | A record request moves to any new status. |
recordRequest.processing | A record request is sent to the facility and is awaiting records. |
recordRequest.needsResolution | A record request is blocked on your action, such as a denied authorization, a pending payment or a site-specific authorization. |
recordRequest.completed | Records were received, fully or partially. |
recordRequest.recordsNotFound | The facility could not provide records. |
recordRequest.canceled | A record request is canceled, voided or marked a duplicate. |
recordRequest.followUpDue | A record request’s follow-up date has arrived. Sent at most once a day while the date is due, and needs the ConfigureFollowUpNotifications permission. |
siteSonar.started | Site Sonar starts for a patient. |
siteSonar.completed | Site Sonar finishes for a patient, on every run, whatever the result. |
patientHistories.started | Patient Histories starts for a patient. |
patientHistories.completed | Patient Histories finishes for a patient, on every run, whatever the result. |
ehrSearch.started | EHR Search starts for a patient. |
ehrSearch.completed | EHR Search finishes for a patient, on every run, whatever the result. |
patient.created | A patient is created. |
patient.updated | A patient’s name, date of birth, sex, contact details or ZIP codes change. |
patient.searchStatusChanged | A patient’s overall searchStatus changes. |
authorization.statusChanged | A patient’s authorization status changes. |
authorization.approved | A patient’s authorization is approved. |
authorization.needsAttention | A patient’s authorization moves to any status other than approved. |
clinicalData.added | A run adds clinical data a patient did not already have, with counts of what is new by type. |
note.created | A note is added to a patient or a record request. |
note.updated | A note’s text is edited. |
aiResearch.completed | An AI research run you started finishes. |
A status change sends recordRequest.statusChanged and, when the request
moves into a new category, the category event too: a request that moves from
RequestSent to Completed sends both recordRequest.statusChanged and
recordRequest.completed.
To learn that a search finished, subscribe to its .completed event. It is
sent once per run, whether or not the run found anything; a patient created
with a search already requested also sends that search’s .started.
Choose the scope
| Setting | Values |
|---|---|
| Projects | Every project in your account, including new ones (the default), or only the projects you pick. |
| Records | Everyone’s records you can see (the default), or only records you created or track, and everything beneath them. |
Picked projects must all be sandbox or all production; the endpoint takes their environment. An endpoint only ever receives records you can see, and only the fields your role can see. If your access changes, what you receive changes with it.
Read the payload
Every delivery is a POST with this JSON body:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"timestamp": "2026-06-24T12:34:56Z",
"version": "events",
"eventType": "recordRequest.completed",
"resource": "companies/1/projects/p_123/patients/pat_456/record-requests/req_789",
"changed": ["status"],
"data": { "id": "req_789", "status": "Completed", "patientId": "pat_456" }
}
| Field | What it is |
|---|---|
id | The delivery’s ID. It stays the same across retries, so deduplicate on it. |
timestamp | When the event was emitted, not when this attempt was sent. |
version | Always events. A deprecated Classic delivery has no version, so a receiver can branch on its presence. |
eventType | The event’s name, such as recordRequest.completed. |
resource | The path to the record. For clinicalData.added it is your company, and for aiResearch.completed the researched site. |
changed | The payload fields whose value changed. Empty when the event is not a change, such as .created or recordRequest.followUpDue. |
data | The group’s fixed payload, below. |
Every event in a group sends the same data. A field your role cannot see is
left out.
| Group | data fields |
|---|---|
recordRequest | id, status, requestType, submittedSiteName, followUpDate, lastContactDate, completionDate, startDateOfService, endDateOfService, createdAt, updatedAt, patientId, projectId, companyId |
siteSonar | id, siteSonar, searchStatus, projectId, companyId |
patientHistories | id, patientHistories, searchStatus, projectId, companyId |
ehrSearch | id, ehrSearch, searchStatus, projectId, companyId |
patient | id, firstName, lastName, dateOfBirth, sex, zipCodes, mobile, email, createdAt, searchStatus, authorizationStatus, projectId, companyId |
authorization | id, authorizationStatus, projectId, companyId |
note | id, content, createdAt, updatedAt, companyId |
clinicalData | patients: each patient, their project, the run that produced the counts, and the counts of what is new by type |
aiResearch | siteId, siteName, url |
data is the record as it stood when the event was processed. If several
changes land within moments of each other, data can already show the later
ones, and two status moves in quick succession can arrive as one
recordRequest.statusChanged. The same holds for searches: a search started
again moments after the last one finished can arrive as one .started and
.completed pair rather than two. Events from different groups on one
record can also arrive out of order when they land moments apart: a
patient.searchStatusChanged can arrive before the siteSonar.started that
came first. To read anything else, such as deliverables or download URLs,
GET the resource path with your API key.
Verify a delivery
Each delivery carries three headers:
| Header | Meaning |
|---|---|
X-Webhook-Id | The delivery id. |
X-Webhook-Timestamp | Unix seconds when this attempt was signed. |
X-Webhook-Signature | One or more hex-encoded HMAC-SHA256 signatures, comma-separated. |
To verify one:
- Reject the request if
X-Webhook-Timestampis older than your freshness window, for example five minutes. This stops a captured request being replayed. - Compute the hex-encoded HMAC-SHA256 of
{X-Webhook-Timestamp}.{raw_body}, using the exact bytes you received asraw_body. - Accept the request if your value matches any signature in
X-Webhook-Signature. Compare in constant time.
Key the HMAC with the signing secret’s 16 raw UUID bytes, not its string form. Hashing the string produces a signature that never matches.
Both samples below take the signing secret as you copied it, the two headers, and the raw request body, before any JSON parsing.
import hashlib
import hmac
import time
import uuid
FRESHNESS_SECONDS = 300
def verify(secret: str, timestamp: str, signatures: str, raw_body: bytes) -> bool:
if abs(time.time() - int(timestamp)) > FRESHNESS_SECONDS:
return False
key = uuid.UUID(secret).bytes
expected = hmac.new(key, f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(
hmac.compare_digest(expected, candidate.strip())
for candidate in signatures.split(",")
)
import { createHmac, timingSafeEqual } from "node:crypto";
const FRESHNESS_SECONDS = 300;
export function verify(
secret: string,
timestamp: string,
signatures: string,
rawBody: Buffer,
): boolean {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > FRESHNESS_SECONDS) {
return false;
}
const key = Buffer.from(secret.replace(/-/g, ""), "hex");
const expected = createHmac("sha256", key)
.update(`${timestamp}.`)
.update(rawBody)
.digest();
return signatures.split(",").some((candidate) => {
const received = Buffer.from(candidate.trim(), "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
});
}
During a secret rotation the header carries two signatures. Accepting any match is what keeps deliveries flowing; see Manage webhook endpoints.
Respond
Return any 2xx within 10 seconds. Anything else, including a timeout, counts
as a failure and the delivery is retried. See
Handle webhook retries and failures.
Send a test
Send test on the Notifications tab sends one sample payload per group you subscribe to, shaped like the real event, to your URL and shows the result. A test is sent once, never retried, and never counts toward auto-disable.
Classic endpoints (deprecated)
Endpoints created before named events use Classic column subscriptions. They
keep receiving exactly the deliveries they always did, but no new Classic
endpoint can be created. Each Classic endpoint on the Notifications tab shows
an Upgrade to Events button that previews how its subscriptions map to
named events, which of its columns are still in a payload, and which you would
now read with a GET, before you confirm. The URL, secret and headers carry
over.
A Classic subscription names four things:
| Part | Values |
|---|---|
| Entity | recordRequest, patient or note |
| Trigger | rowCreated (the entity was created), columnChanged (a subscribed column changed) or followUpDue (a record request’s follow-up date is due; record requests only) |
| Scope | companyWide (every matching record you can see, the default) or tracked (only records you created or track) |
| Columns | camelCase column keys, such as status or searchStatus |
A Classic delivery’s eventType is <entity>.<trigger>, such as
recordRequest.columnChanged, its data holds only the subscribed columns,
and its body has no version field. Otherwise it is the envelope above, signed
and retried the same way.
On a Classic endpoint, do not subscribe to
searchStatusalone to learn that a search finished. A repeat run that ends in the samesearchStatuschanges nothing and sends nothing; watch thesiteSonar,patientHistoriesandehrSearchrequest flags instead, or upgrade and use the.completedevents.
Move a Classic endpoint to named events
Upgrading switches an endpoint’s deliveries from the Classic format to named events in one step. Upgrading is permanent: an upgraded endpoint cannot go back to Classic.
What your receiver sees change:
| Classic | Named events | |
|---|---|---|
eventType | <entity>.<trigger>, such as recordRequest.columnChanged | The event’s name, such as recordRequest.completed |
data | The columns you picked | The group’s fixed payload |
changed | Subscribed columns that changed | Payload fields that changed |
version | Absent | events |
| Scope | Set on each subscription | Set once on the endpoint |
The URL, signing secret, custom headers, signature scheme, X-Webhook-Id and
retries stay the same.
How Classic subscriptions map to events:
| Classic subscription | Named event |
|---|---|
recordRequest created | recordRequest.created |
recordRequest status changed | recordRequest.statusChanged (add the category events, such as recordRequest.completed, if you only care about outcomes) |
recordRequest follow-up due | recordRequest.followUpDue |
patient created | patient.created |
patient siteSonar, patientHistories or ehrSearch changed | That capability’s .started and .completed |
patient searchStatus changed | patient.searchStatusChanged |
patient authorizationStatus changed | authorization.statusChanged |
patient profile columns (name, date of birth, sex, contact details, ZIP codes) changed | patient.updated |
note created | note.created |
note content changed | note.updated |
| Clinical data added or AI research completed from the Entity dropdown’s Events group | clinicalData.added or aiResearch.completed |
Any other column stops triggering a delivery. The upgrade preview says, for
each one, whether it is still in a group’s payload or whether you now read it
with a GET on the resource path.
To upgrade without dropping a delivery:
- Update your receiver to accept both formats. Branch on
version: a body with"version": "events"is a named event, and a body without it is Classic. Deploy that first. - Open the preview. On the Notifications tab, select Upgrade to Events on the Classic endpoint. The preview lists each subscription, the events it becomes, and what happens to each column. The events are pre-ticked, and you can add or remove any before you confirm.
- Check the scope. Named events use one scope for the whole endpoint. If your subscriptions had different scopes, the upgrade takes the broadest one and the preview says so. Narrow it there, or split the work across two endpoints.
- Confirm. The next change sends named events. Select Send test to see a sample of each subscribed group’s payload.
- Remove the Classic branch from your receiver once named events are arriving.
To run both side by side instead, create a new endpoint with named events at a second URL, move your integration across, then delete the Classic endpoint.
Next steps
- Handle webhook retries and failures: what happens when your receiver is down, and what webhooks don’t guarantee.
- Manage webhook endpoints: rotate the secret, and change an endpoint’s events.
- Request records from a facility: the status changes most integrations subscribe to.