Skip to content
Hermes Health API
API referenceOpenAPI spec

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

  1. Sign in to Hermes Health and open Settings → API → Notifications.
  2. Select Add a notification and choose the Webhook channel.
  3. Enter your receiver’s URL. It must be publicly resolvable; private and loopback addresses are rejected.
  4. Choose the environment, production or sandbox. An endpoint receives events only from projects in its own environment.
  5. Pick the events (next section): tick a whole group, or single events.
  6. Choose the scope: which projects, and whose records.
  7. Optionally, add up to 20 custom headers sent on every delivery, such as an Authorization header your receiver checks.
  8. Copy the endpoint’s signing secret. You need it to verify deliveries.

Creating a webhook endpoint requires the ManageWebhooks permission, because a webhook can send PHI to an external URL. You cannot override the Content-Type, Host or X-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.

GroupCovers
recordRequestA record request is created, changes status, or reaches its follow-up date.
siteSonarSite Sonar facility discovery starts or finishes for a patient.
patientHistoriesPatient Histories clinical-history retrieval starts or finishes for a patient.
ehrSearchEHR Search starts or finishes for a patient.
patientA patient is created, their profile changes, or their overall search status changes.
authorizationA patient’s authorization is reviewed or needs attention.
clinicalDataA run adds clinical data the patient did not already have. Webhook endpoints only.
noteA note is added or edited.
aiResearchAn AI research run you started finishes. Sent only to your own endpoints.

The events:

EventFires when
recordRequest.createdA record request is created.
recordRequest.statusChangedA record request moves to any new status.
recordRequest.processingA record request is sent to the facility and is awaiting records.
recordRequest.needsResolutionA record request is blocked on your action, such as a denied authorization, a pending payment or a site-specific authorization.
recordRequest.completedRecords were received, fully or partially.
recordRequest.recordsNotFoundThe facility could not provide records.
recordRequest.canceledA record request is canceled, voided or marked a duplicate.
recordRequest.followUpDueA 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.startedSite Sonar starts for a patient.
siteSonar.completedSite Sonar finishes for a patient, on every run, whatever the result.
patientHistories.startedPatient Histories starts for a patient.
patientHistories.completedPatient Histories finishes for a patient, on every run, whatever the result.
ehrSearch.startedEHR Search starts for a patient.
ehrSearch.completedEHR Search finishes for a patient, on every run, whatever the result.
patient.createdA patient is created.
patient.updatedA patient’s name, date of birth, sex, contact details or ZIP codes change.
patient.searchStatusChangedA patient’s overall searchStatus changes.
authorization.statusChangedA patient’s authorization status changes.
authorization.approvedA patient’s authorization is approved.
authorization.needsAttentionA patient’s authorization moves to any status other than approved.
clinicalData.addedA run adds clinical data a patient did not already have, with counts of what is new by type.
note.createdA note is added to a patient or a record request.
note.updatedA note’s text is edited.
aiResearch.completedAn 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

SettingValues
ProjectsEvery project in your account, including new ones (the default), or only the projects you pick.
RecordsEveryone’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:

Delivery envelope
json
{
  "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" }
}
FieldWhat it is
idThe delivery’s ID. It stays the same across retries, so deduplicate on it.
timestampWhen the event was emitted, not when this attempt was sent.
versionAlways events. A deprecated Classic delivery has no version, so a receiver can branch on its presence.
eventTypeThe event’s name, such as recordRequest.completed.
resourceThe path to the record. For clinicalData.added it is your company, and for aiResearch.completed the researched site.
changedThe payload fields whose value changed. Empty when the event is not a change, such as .created or recordRequest.followUpDue.
dataThe group’s fixed payload, below.

Every event in a group sends the same data. A field your role cannot see is left out.

Groupdata fields
recordRequestid, status, requestType, submittedSiteName, followUpDate, lastContactDate, completionDate, startDateOfService, endDateOfService, createdAt, updatedAt, patientId, projectId, companyId
siteSonarid, siteSonar, searchStatus, projectId, companyId
patientHistoriesid, patientHistories, searchStatus, projectId, companyId
ehrSearchid, ehrSearch, searchStatus, projectId, companyId
patientid, firstName, lastName, dateOfBirth, sex, zipCodes, mobile, email, createdAt, searchStatus, authorizationStatus, projectId, companyId
authorizationid, authorizationStatus, projectId, companyId
noteid, content, createdAt, updatedAt, companyId
clinicalDatapatients: each patient, their project, the run that produced the counts, and the counts of what is new by type
aiResearchsiteId, 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:

HeaderMeaning
X-Webhook-IdThe delivery id.
X-Webhook-TimestampUnix seconds when this attempt was signed.
X-Webhook-SignatureOne or more hex-encoded HMAC-SHA256 signatures, comma-separated.

To verify one:

  1. Reject the request if X-Webhook-Timestamp is older than your freshness window, for example five minutes. This stops a captured request being replayed.
  2. Compute the hex-encoded HMAC-SHA256 of {X-Webhook-Timestamp}.{raw_body}, using the exact bytes you received as raw_body.
  3. 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.

Verify a delivery
Python
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:

PartValues
EntityrecordRequest, patient or note
TriggerrowCreated (the entity was created), columnChanged (a subscribed column changed) or followUpDue (a record request’s follow-up date is due; record requests only)
ScopecompanyWide (every matching record you can see, the default) or tracked (only records you created or track)
ColumnscamelCase 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 searchStatus alone to learn that a search finished. A repeat run that ends in the same searchStatus changes nothing and sends nothing; watch the siteSonar, patientHistories and ehrSearch request flags instead, or upgrade and use the .completed events.

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:

ClassicNamed events
eventType<entity>.<trigger>, such as recordRequest.columnChangedThe event’s name, such as recordRequest.completed
dataThe columns you pickedThe group’s fixed payload
changedSubscribed columns that changedPayload fields that changed
versionAbsentevents
ScopeSet on each subscriptionSet 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 subscriptionNamed event
recordRequest createdrecordRequest.created
recordRequest status changedrecordRequest.statusChanged (add the category events, such as recordRequest.completed, if you only care about outcomes)
recordRequest follow-up duerecordRequest.followUpDue
patient createdpatient.created
patient siteSonar, patientHistories or ehrSearch changedThat capability’s .started and .completed
patient searchStatus changedpatient.searchStatusChanged
patient authorizationStatus changedauthorization.statusChanged
patient profile columns (name, date of birth, sex, contact details, ZIP codes) changedpatient.updated
note creatednote.created
note content changednote.updated
Clinical data added or AI research completed from the Entity dropdown’s Events groupclinicalData.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:

  1. 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.
  2. 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.
  3. 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.
  4. Confirm. The next change sends named events. Select Send test to see a sample of each subscribed group’s payload.
  5. 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