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:
- Wildcards are a subscription convenience only. Installation grants are always concrete types, and a wildcard is accepted only when every event in the family has been granted — otherwise the endpoint write fails listing the uncovered types.
- A wildcard automatically covers events added to that family by future platform versions (once the clinic's grant covers them).
- The SDK types subscriptions as
WebhookEventPattern(a concreteWebhookEventTypeor a<family>.*template string) and exportsEVENT_FAMILIESplusmatchesPattern(type, pattern)mirroring the server semantics.
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
- At-least-once: dedupe by
id(Dentolize-Event-Id). - Respond
2xxwithin 10 seconds; anything else (including redirects) counts as failure. - Retries: 8 attempts with exponential backoff (≈30s doubling, spanning roughly 2.5 days).
- After 20 consecutive exhausted deliveries your endpoint is auto-disabled and the clinic is notified; re-enable it from the plugin settings page.
- The delivery log (event, attempts, response code, response snippet) is visible to the clinic in the settings Activity tab, with manual redelivery.
- Endpoints must be HTTPS on a public host (private/link-local addresses are rejected). The sandbox relaxes this for tunnel/local development.
- Test your handler end-to-end with the Send test event button (or
POST /api/v1/webhook-endpoints/:id/test-event) — fixtures flow through the real signing and delivery pipeline.