Dentolize · Plugin Platform Walkthrough
On this pageBusiness viewTechnical view

Webhooks

Business view

Rather than making a plugin constantly poll Dentolize asking "did anything change?", Dentolize pushes it a notification the moment something relevant happens — a new appointment, a completed payment, a signed quotation. This is how the reminders demo plugin knows to fire off a text message the instant an appointment is booked, without checking every few seconds.

These notifications are deliberately thin: an event says "appointment 123 changed to CONFIRMED", not "here is patient Jane Doe's full appointment record." If the plugin needs the details, it calls the REST API for them — which means every time a plugin actually reads something sensitive, that read passes back through the same scope and audit-log checks described in Scopes & consent. Patient names and phone numbers never travel in a webhook payload.

Because networks are unreliable, Dentolize doesn't just fire once and hope: a failed delivery is retried automatically over roughly two and a half days, and if a plugin's endpoint is unreachable for long enough, Dentolize disables it and tells the clinic, rather than quietly retrying forever.

Technical view

Event catalog

44 event types (packages/server/src/plugins/webhooks/events.js, 318 lines), resource.verb past-tense, spanning patients, appointments, invoices/payments, lab orders, treatments, quotations, prescriptions, encounters/measurements, forms, files/x-rays, video calls, insurance claims, online payments, and e-invoicing. A few worth calling out because their behavior is non-obvious:

  • Status-transition events fire the specific verb, not a generic .updatedappointment.cancelled/.checkedin/.completed/.noshow, laborder.received/.delivered, claim.status_changed, onlinepayment.succeeded/.failed/.refunded. A partner should subscribe to the transitions they actually care about rather than diffing a generic update.
  • quotation.viewed is registered but not yet emitted — it exists in the catalog (so a plugin can subscribe without error) for a planned future patient-portal release, but no delivery will ever occur for it today.
  • measurement.recorded only fires from the plugin REST endpoint POST /encounters/:id/measurements — a vital recorded through the normal clinic UI does not emit this event. Its payload key is measurementName, not name.
  • einvoice.cleared: the payload's data.status field reads SUBMITTED — the CLEARED enum value is never actually persisted to the database. Clearance is signaled by which event type fired, not by a status value inside it. A partner building a compliance dashboard needs to know this or they'll never see a "cleared" status string anywhere.
  • Online-payment terminal events (onlinepayment.succeeded/.failed/.refunded) are dedupe-guarded against duplicate gateway callbacks — they only fire when the final status genuinely changes.

Envelope and signing

{
  "id": "evt_9f7c…", "type": "appointment.updated", "apiVersion": "2026-07",
  "createdAt": "2026-07-23T10:15:00.000Z", "companyId": "…",
  "data": { "id": "…", "object": "appointment", "status": "CONFIRMED" },
  "previousAttributes": { "status": "OPEN" }
}

Signed with Dentolize-Signature: t=<unixSeconds>,v1=<hex> where the hex is HMAC-SHA256(secret, "<t>.<rawBody>"), using the endpoint's own whsec_… secret (packages/server/src/plugins/webhooks/crypto.js; secrets are stored encrypted at rest with AES-256-GCM, format iv:ciphertext:authTag, keyed by the WEBHOOK_SECRET_KEY env var — never stored or logged in plaintext). Receivers should verify against the raw request body before parsing JSON, and reject timestamps older than 5 minutes. The SDK's constructEvent() (packages/plugin-sdk/src/webhooks.ts) does this verification for you.

Delivery, retries, and auto-disable

Delivery is at-least-once — a plugin dedupes by id/Dentolize-Event-Id. A 2xx within 10 seconds counts as success; anything else, including a redirect, counts as failure. The BullMQ worker (packages/server/src/plugins/webhooks/deliverJob.js) retries with exponential backoff (BACKOFF_MS * 2^(attemptNumber-1), line 261), and the final failed attempt marks the delivery EXHAUSTED rather than FAILED (line 266). After PLUGIN_WEBHOOK_MAX_CONSECUTIVE_FAILURES (env-tunable, default 20, deliverJob.js:7) consecutive exhausted deliveries, the endpoint is auto-disabled and the clinic notified — re-enabling is a manual toggle on the Connection tab.

SSRF protection lives in the same file: outside test mode, private/link-local/loopback address ranges (0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 192.168.0.0/16, IPv6 link-local fe80::/10, etc. — deliverJob.js:18-32) are rejected as webhook destinations, so a plugin can't register an endpoint pointing back into Dentolize's own infrastructure. The sandbox relaxes this specifically to allow tunnel/local development URLs.

Testing a handler

The Send test event action (Connection tab, or POST /api/v1/webhook-endpoints/:id/test-event) sends a real fixture event through the actual signing and delivery pipeline — not a mocked call — so a failed test in the Walkthrough (delivering to a URL that doesn't exist) produced a genuine FAILED row in the Activity tab with a real retry-eligible delivery, exactly as a real integration failure would.