Webhooks

Dentolize pushes domain events to your HTTPS endpoint with an HMAC signature. Payloads are thin: ids and minimal state, never patient names, phones, or other PII — fetch details through the API so every PII disclosure stays scoped and audited.

Event catalog

Type Fired when
patient.created / patient.updated / patient.deleted Patient lifecycle (deleted ⇒ purge your copies within 30 days)
appointment.created / appointment.updated / appointment.cancelled Appointment lifecycle
appointment.checkedin Patient checked in for an appointment
appointment.completed Appointment completed
appointment.noshow Patient did not show up
invoice.created / invoice.paid Invoice issued / fully paid
invoice.updated Invoice edited or its financial state changed
payment.created Payment recorded
payment.refunded Payment refunded (fully or partially — data.partial)
installation.uninstalled The clinic uninstalled your plugin ⇒ purge all clinic data
plugin.settings.updated Your settings values changed (by the clinic, or by your own PATCH /installation/settings)
laborder.created / laborder.updated Lab order created / edited
laborder.received Lab order marked received back from the lab
laborder.delivered Lab order marked delivered to the patient
treatment.status_changed A treatment (operation) changed status
treatmentstep.completed A treatment step transitioned to COMPLETED
quotation.created A new quotation was created
quotation.viewed Reserved — not yet emitted. Registered in the catalog for a future patient-portal release; you can subscribe, but no deliveries occur today
quotation.signed A quotation was signed (accepted)
prescription.created A new prescription was issued
encounter.created A new patient encounter was recorded
measurement.recorded A vital was recorded — fired only by the plugin REST POST /encounters/{id}/measurements; the vital key in data is measurementName (not name)
form.submitted A form submission transitioned to SUBMITTED
form.signed A form instance was signed
file.created A file was uploaded to a patient record
xray.created An x-ray record was created
videocall.started A participant joined an appointment video call
claim.created A new insurance claim was created
claim.status_changed A claim transitioned status — fired by every transition mutation: PLANNING → IN_REVIEW → PARTIALLY_CLAIMED → FULLY_CLAIMED, plus the revert IN_REVIEW → PLANNING; previousAttributes.status carries the prior status
claim.rejected_amount_updated The rejected amount on a claim changed — change-guarded (fires only when the value actually differs)
treatment.approval_requested A treatment was created requiring insurance pre-authorization (creation is the only site setting approvalRequired)
treatment.approved A pre-auth treatment was approved by the insurer (approved: null|false → true)
onlinepayment.succeeded An online (gateway) payment reached SUCCESS — data.paymentId links the recorded platform payment
onlinepayment.failed An online payment reached FAILED
onlinepayment.refunded An online payment was refunded by the provider (REFUND)
einvoice.submitted A simplified (B2C) e-invoice was successfully reported to the tax authority
einvoice.cleared A standard (B2B) e-invoice was successfully cleared. Derivation nuance: the payload's data.status is SUBMITTED — the CLEARED enum value is never written to the database; clearance is expressed by the event type itself (it fires from the ZATCA clearance branch)
einvoice.rejected An e-invoice submission was rejected — data.errorMessage carries the first error, truncated to 200 chars
lead.created A new lead (pre-patient CRM record) was created — data.stageId, data.sourceId for campaign attribution
lead.stage_changed A lead moved pipeline stages; previousAttributes.stageId carries the prior stage
lead.converted A lead became a full patient record — data.patientId is the new patient id
lead.rejected A reject reason was recorded on a lead (data.rejectReasonId)
message.sent An outbound communication (WhatsApp/SMS) was sent successfully — data.communicationId, data.channel
message.failed An outbound communication definitively failed (attempts exhausted or no channel)
conversation.message_received An inbound WhatsApp message arrived on an online conversation (outbound/bot messages do not fire it). Thin by contract: data carries conversationId, messageId and — when the conversation is linked — patientId/leadId; never message content or phone numbers. Pull the text via GET /conversations/{id}/messages, or resolve the sender with GET /patients/lookup
conversation.assigned An online conversation was assigned to a user (data.assignedToId)
conversation.resolved An online conversation was marked as resolved
points.earned A patient earned loyalty points (data.points positive, data.action)
points.redeemed A patient redeemed or transferred out loyalty points (data.points negative)
feedback.submitted A patient submitted feedback for a completed appointment (data.rating)
task.created A task instance was created — data.automatic: true when a recurring-task cron created it
task.completed A task instance was marked as completed
inventory.low_stock An inventory mutation left an item below its minimum stock amount. Emitted post-commit from the committed row, so data.amount/data.minAmount are authoritative; data.inventoryItemId (+ data.subItemId when the low record is a batch/variant sub-item). Level-triggered like the in-app stock alert — every qualifying mutation re-emits while the item stays low; treat it as "currently low", not a one-shot transition
inventoryorder.created A new inventory order was created (data.status, data.type, data.supplierId)
inventoryorder.received An inventory order was completed/received into stock
expense.created An expense was recorded — data.automatic: true for the auto-monthly cron
income.created An income record was created — data.expenseRefund: true for expense-refund incomes

Status transitions fire the specific verb (appointment.cancelled / .checkedin / .completed / .noshow, laborder.received / .delivered, claim.status_changed, onlinepayment.succeeded / .failed / .refunded) instead of a generic .updated — subscribe to the transitions you care about. Online-payment terminal events are dedupe-guarded against double gateway callbacks (they fire only when the final status actually changes).

Wildcard subscriptions

Endpoint subscriptions (POST /webhook-endpoints, events on the settings page) accept family wildcards alongside concrete types: appointment.* means every registered event whose family — the segment before the first dot — is appointment. Rules:

Envelope

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

previousAttributes is null when not applicable.

Verifying signatures

Headers: Dentolize-Signature: t=<unixSeconds>,v1=<hex>, plus Dentolize-Event-Id and Dentolize-Event-Type. The signature is HMAC-SHA256(secret, "<t>.<rawBody>") using your endpoint's whsec_… secret. Reject if the timestamp is older than 5 minutes (replay protection). Verify against the raw request body — parse JSON only after verification.

import express from 'express'
import { constructEvent } from '@dentolize/plugin-sdk'

app.post('/webhooks/dentolize', express.raw({ type: 'application/json' }), (req, res) => {
  let event
  try {
    event = constructEvent({
      payload: req.body.toString('utf8'),
      signatureHeader: req.headers['dentolize-signature'],
      secret: process.env.DENTOLIZE_WEBHOOK_SECRET
    })
  } catch {
    return res.sendStatus(400)
  }
  res.sendStatus(200) // ack fast; process async
  handle(event)
})

Delivery semantics