Webhooks
Webhooks let HealthEx notify your systems the moment something happens, instead of you polling us to find out. You register an HTTPS endpoint, subscribe it to one or more event types, and scope it to the projects you care about. When a subscribed event occurs, we send it to your endpoint as a signed JSON payload.
Webhooks are strictly one-directional. HealthEx sends you an HTTP POST; you acknowledge it with any 2xx response. Nothing in this system calls back into HealthEx, and your response body is ignored — the status code is the entire acknowledgement.
How Delivery Works
| Transport | HTTPS POST only. Non-HTTPS endpoints are rejected when you register them. |
| Content type | application/json |
| Acknowledgement | Any 2xx status, returned within 10 seconds |
| Delivery guarantee | At-least-once. You may receive the same event more than once. |
| Ordering | Not guaranteed. Order on the envelope's timestamp, never on arrival time. |
| Latency | The first attempt is dispatched within seconds of the triggering action. For consent.withdrawn, HealthEx targets sub-minute p95 delivery. |
| Retries | Seven retries after a failed first attempt, spread over roughly 33 hours. See Retries and Failures. |
Because delivery is at-least-once and unordered, your handler needs to be idempotent. Idempotency and Ordering covers how to do that with a few lines of code.
The Event Envelope
Every event we deliver — current and future — uses the same envelope. Only data varies by event type.
{
"type": "consent.withdrawn",
"timestamp": "2026-08-19T14:32:07.421Z",
"apiVersion": 1,
"deliveryAttempt": 1,
"organizationId": "56696fdb-2e0d",
"projectId": "694d61c2-3f1b-4dc8",
"data": {
"patientId": "17d4513f-73e8-4b2a-9c1d-5e6f7a8b9c0d",
"consentType": "PATIENT_DIRECTED_DATA_EXCHANGE",
"withdrawnAt": "2026-08-19T14:32:05.891Z",
"fullRecordWithoutRestrictedData": true,
"consentDataResourceScopes": null,
"consentDataSensitivityScopes": null,
"withdrawalSource": "PATIENT",
"effectiveImmediately": true
}
}
Envelope Fields
| Field | Description |
|---|---|
type | The event type, in resource.action form. Switch on this field to route the event. |
timestamp | ISO 8601 timestamp of when the event occurred. Stable across retries — every attempt at delivering one event carries the same value. |
apiVersion | Integer schema version for the envelope and data. Currently 1. |
deliveryAttempt | 1 on the first attempt, incrementing on each retry. Useful for your own logging. Not an idempotency key. |
organizationId | Your HealthEx organization. |
projectId | The project the event pertains to. Always present, including on webhooks scoped to all projects. |
data | The event-specific payload, defined per event type. |
The unique identifier for an event is the webhook-id header, not a body field. That is what you deduplicate on.
Request Headers
POST /webhooks/healthex HTTP/1.1
Content-Type: application/json
webhook-id: evt_01K2YXQ8N3M4P5Q6R7S8T9V0W1
webhook-timestamp: 1755613927
webhook-signature: v1,G60wTDnmPxHNTUtESI0wOL8m5xFTvCvPyWXBUuIIBoU=
| Header | Description |
|---|---|
webhook-id | Unique event identifier, evt_ followed by a 26-character ULID. Constant across every retry of the same event. Use this as your idempotency key. |
webhook-timestamp | Unix timestamp in seconds for this specific delivery attempt. Changes on every retry. Use it to reject stale deliveries. |
webhook-signature | One or more signatures over this request. See Verifying Signatures. |
The distinction between the two timestamps matters. The body's timestamp tells you when the event happened; the webhook-timestamp header tells you when this attempt was sent. A retry 24 hours later carries the original body timestamp and a fresh header webhook-timestamp.
We do not send the event type, the attempt number, or a per-attempt delivery id as headers. Read type and deliveryAttempt from the body.
Verifying Signatures
Every delivery is signed with your webhook's signing secret so you can confirm it came from HealthEx and was not altered in transit. HealthEx implements the Standard Webhooks specification, so you can use an off-the-shelf verification library in most languages rather than writing the check yourself.
The algorithm, in short:
- Take your signing secret, strip the leading
whsec_, and base64-decode the remainder into 32 raw bytes. That decoded value is the HMAC key. - Build the signed string by joining three parts with periods:
{webhook-id}.{webhook-timestamp}.{raw request body}. - Compute
HMAC-SHA256over that string using the key from step 1, and base64-encode the result. - Compare it, using a constant-time comparison, against each space-separated value in the
webhook-signatureheader, ignoring thev1,prefix. If any one matches, the request is authentic. - Reject the delivery if
webhook-timestampis more than five minutes from your current time.
The HMAC key is the base64-decoded bytes of the secret, not the whsec_… string itself. Using the raw string produces a signature that never matches, and is a common reason for failure.
Verifying Signatures has runnable examples in Node.js, Python, and Go — both using a Standard Webhooks library and implementing the check from scratch.
Event Naming
Event types use lowercase resource.action form. The convention is enforced when events are defined, so every event type you ever receive will follow it.
| Event type | Description |
|---|---|
consent.withdrawn | A patient revoked a previously-granted authorization for one of your projects. See the event reference. |
webhook.test | A test event you trigger yourself from the Admin console. Its data is a single nonce field. Never emitted by real patient activity. |
Project Scoping
Every webhook is registered with a project scope, which decides which events reach it:
- Single project — receives events for one project only.
- Multiple projects — receives events for an explicit set of projects you choose.
- All projects — receives events for every project in your organization, including projects created after the webhook was registered.
An event is delivered to a webhook when the webhook is active, its scope covers the event's projectId, and its subscribed event types include the event's type. Filtering happens on the HealthEx side, so you never receive events outside your scope.
Scopes may overlap. If two webhooks both cover a project and both subscribe to the event, both receive it — which is a supported way to run redundant or separately-routed receivers. Each endpoint gets its own signature and its own independent retry chain, but they all share one webhook-id, so a single event fanned out to two of your endpoints carries the same identifier at both.
Scope to All projects if you want new projects covered automatically and you're happy to route internally on projectId. Scope to specific projects if you'd rather deliberately opt each new project in — but remember that a project added later will not send events until you add it to the scope.
Future Events
New event types will reuse everything on this page: the same envelope, the same three headers, the same signing scheme, the same project scoping, and the same Admin console screens.
Practically, that means a receiver written today keeps working as events are added. You will only ever receive event types you explicitly subscribed to, so a new event type cannot arrive unannounced.
Switch on type and ignore event types you don't recognize rather than erroring on them. Treat single-value fields such as withdrawalSource as open enumerations — new values may appear without a change to apiVersion.
A webhook tells you that something changed; it is not a substitute for checking consent state before you use patient data. Deliveries can be delayed by an outage on either side, and events are dropped entirely while a webhook is paused. Verify current consent via the API as described in Basic Consent Checking.
Next Steps
- Setting Up a Webhook: Register an endpoint and store your signing secret
- The
consent.withdrawnEvent: Complete event reference with example payloads - Verifying Signatures: Worked examples in Node.js, Python, and Go
- Retries and Failures: What we do when your endpoint doesn't answer
- Idempotency and Ordering: Handling at-least-once delivery safely
- Troubleshooting: Reading the delivery log and rotating secrets