Skip to main content

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:
No patient name, no phone number, no appointment time. That is deliberate. A webhook body travels to an endpoint we do not control and cannot audit, so it carries an identifier rather than PHI: an intercepted delivery leaks a UUID, not a patient. You fetch the record yourself, with your own key, over an authenticated connection that is logged. So every handler has the same shape: verify → deduplicate → check environment → fetch → act.

A complete handler

The four things that bite

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.
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.
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.
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 to appointment.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 to lab.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 to charge-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 301 to 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.