Webhooks in Practice
The webhooks reference covers the payload, the headers and the settings. This page is about actually building on them: what a correct handler looks like, why each part of it exists, and the mistakes that only show up in production.The mental model: a doorbell, not a delivery
A webhook tells you that something changed and gives you its id. It does not carry the record:A complete handler
The four things that bite
Verifying the parsed body instead of the raw one
Verifying the parsed body instead of the raw one
JSON.parse then JSON.stringify reorders keys and changes whitespace, so the
bytes you hash are not the bytes we signed. In Express this means
express.raw({ type: 'application/json' }), not express.json(). Symptom:
every signature fails, and it looks like the secret is wrong.Assuming exactly-once delivery
Assuming exactly-once delivery
Delivery is at-least-once. A handler that times out after doing its work
still gets retried, because we never saw the 200. Deduplicate on
X-Clinik-Delivery-Id (also the payload’s id) before acting. Skipping this is
how one booking becomes three text messages.Letting sandbox events drive real work
Letting sandbox events drive real work
Webhooks are registered per organisation, not per key, so an endpoint receives
test and live events alike. Every integration test you run fires the same
appointment.created as a real booking. Check data.environment, and pin your
production endpoint to live when you register it so sandbox traffic never
reaches it at all.There is a second reason to care: resourceId only exists in the datastore its
own environment writes to. Fetching a test resource with a live key returns
404.Doing the work before responding
Doing the work before responding
Failed deliveries retry on a 30s → 2min → 8min → 32min → 2h backoff. A handler
that spends 20 seconds generating a PDF invites duplicates and eventually gets
marked failed. Return
200 as soon as the event is verified and durable, then
process asynchronously.Worked scenarios
Appointment reminders
Subscribe toappointment.created and appointment.updated. On create,
schedule a reminder 24 hours out; on update, reschedule it; on
appointment.deleted, cancel it. Because the reminder is keyed to the
appointment id, the update path is idempotent by construction.
This is the case that most clearly beats polling — not for latency, but because
you would otherwise diff every clinic’s appointment list on a timer, forever.
Critical result escalation
Subscribe tolab.created, fetch the report, and page the on-call clinician
when a value is out of range.
Here latency genuinely matters. A critical potassium result sitting in a
one-minute polling gap is a patient-safety issue, not a performance one — and
the gap is unbounded if your poller is mid-restart.
One platform, many clinics
If you resell to multiple practices, give each one a sub-organization. You then have two options, and the right one depends on your architecture:- One endpoint, route on
data.tenantId. It carries the sub-org id, not your parent organization id, so it is a clean routing key. - One endpoint per clinic, pinned to that sub-org. Their events never reach another clinic’s infrastructure — useful when each customer has their own deployment, or when you want the isolation to be structural rather than a line of code.
Keeping a billing system in sync
Subscribe tocharge-item.created and invoice.updated and push into your
accounting system. Nobody re-keys anything, and the FHIR record stays the
source of truth.
Before you go live
- Register the final URL. Redirects are not followed — a
301to a trailing slash makes every delivery a failure. - The endpoint must be publicly reachable over HTTPS. Loopback, private ranges and internal hostnames are rejected at registration and at delivery.
- Subscribe to the specific events you handle rather than
*. It is cheaper for both of us, and it makes the delivery log readable when something breaks. - Watch Analytics → Webhook Logs in the dashboard after your first deployment. Every attempt is recorded with its status and response, and a refused delivery tells you exactly why.