Dentolize · Plugin Platform Walkthrough
On this pageBusiness viewTechnical view

Clinical & revenue integrations

Business view

Two categories of real-world partner made it into this PR beyond the basic "read appointments, get notified" case:

Clinical partners — the businesses a dental clinic already works with day to day:

  • Dental lab networks, who need to receive new lab orders and report back when a case is ready, without ever seeing pricing.
  • Imaging/PACS vendors and intraoral scanners, who need to push x-rays and scans straight onto a patient's chart.
  • Intake kiosks and external forms, who need to submit a patient's answers on their behalf.
  • Connected medical devices, who need to record vitals (heart rate, oxygen saturation) during a visit.

Revenue partners — the businesses that touch clinic money:

  • POS terminals and payment gateways, who need to tell Dentolize "this invoice was just paid outside your system" and have it show up correctly in reports.
  • Payment link generators, letting a patient pay remotely through the clinic's own configured gateway.
  • Insurance clearinghouses, who need visibility into claim status and rejection reasons across many clinics at once.
  • Compliance dashboards, tracking Saudi ZATCA e-invoice submissions without ever touching the underlying XML or certificates.

The unifying design rule across both groups, stated explicitly in the platform's own roadmap notes: reads ship before writes, and writes stay narrow. A lab-network plugin can mark an order received or set a shade — it can never touch that order's price. A payments plugin can record a payment — it can never issue a refund through the API. Nothing here lets a plugin do something a clinic staff member couldn't already do through the normal app, and several of the narrowest writes (lab shade/notes, vitals, form submissions) can't touch money or identity fields at all.

Technical view

Lab orders (laborders:read/:write)

const order = await client.labOrders.get(event.data.id)
await client.labOrders.update(order.id, { received: true, shade: 'A2', details: 'Zirconia crown, glazed' })

PATCH /lab-orders/:id accepts only received/delivered/shade/details — pricing fields are not reachable through this route at the schema-validation layer, not just by convention. Events: laborder.created, laborder.updated, laborder.received, laborder.delivered.

Imaging / files (files:read/:write)

Two-step presigned upload (see REST API & SDK), wrapped by client.patientFiles.upload({ patientId, buffer, fileName, contentType, kind: 'xray', operationId }). Allowed types: jpeg/png/webp/tiff/bmp/pdf/dicom, ≤100MB. kind: 'xray' additionally creates the chart's Xray record. Events: file.created, xray.created.

Intake forms (forms:read/:write)

A plugin fetches the clinic's own form-builder schema (client.forms.list(), reading currentVersion.schema) and renders it in its own kiosk/portal, then submits answers keyed by field key via client.formSubmissions.create(). Signature-typed answers are stripped from every read — a plugin can confirm a form was signed but never retrieve the signature image. Event: form.submitted (fires on transition to SUBMITTED); form.signed separately for signed instances.

Vitals / encounters (encounters:read/:write)

client.encounters.addMeasurement(encounterId, { name: 'HEART_RATE', value: 72 }) — the platform computes trend fields (improvement, difference, normal-range status) automatically, readable back via encounters.get(). The measurement.recorded webhook payload uses the key measurementName, not name — a common gotcha the docs call out explicitly. This event only fires through this specific REST endpoint, never from vitals entered in the normal clinic UI.

Quotations / treatment plans (quotations:read, financial tier)

const pending = await client.quotations.list({ from, to, signed: false })

Treatment.quotationId, steps[], approvalRequired/approved, and preAuth are exposed via the existing treatments:read scope. Events: quotation.created, quotation.signed (quotation.viewed is registered but not yet emitted — see Webhooks).

External payment recording (payments:write, financial tier)

const payment = await client.payments.create(
  { invoiceId, amount, type: 'CARD', reference: gatewayTxId },
  { idempotencyKey: gatewayTxId }
)

Runs the real platform payment core — treasury routing, invoice/patient totals, the standard payment.created/invoice.paid events. The payment id is derived deterministically from the idempotency key, so even a retry that dodges the 24h response-replay cache collides on id rather than double-recording. Typed errors: 400 amount_exceeds_pending (with details.pendingAmount), 404 invoice_not_found/treasury_not_found, 409 invoice_already_paid, 409 treasury_required (this clinic mandates an explicit treasuryId). BALANCE/INSURANCE types are rejected — those money flows stay app-only.

const link = await client.paymentLinks.create({ invoiceId })  // amount defaults to pending

Creates a hosted page on the clinic's own configured gateway. Outcomes arrive asynchronously via onlinepayment.succeeded/.failed/.refunded — on success, data.paymentId is the platform-recorded payment; a plugin must not separately call payments.create(). 409 payment_provider_not_configured when the clinic has no usable gateway; 502 payment_provider_error when the gateway itself rejects the request (never leaking provider internals).

Claims monitoring (claims:read + insurance:read, financial tier)

claims.get(id) adds invoiceIds (ids only — pair with invoices:read for amounts). Events cover every transition: claim.created, claim.status_changed (PLANNING → IN_REVIEW → PARTIALLY_CLAIMED → FULLY_CLAIMED, plus the revert to PLANNING), and a change-guarded claim.rejected_amount_updated (fires only when the value actually differs). Patient-level insurance detail (number, policy/class ids, percentage, limits) requires the separate patients:read.pii scope, nested on the patient payload.

ZATCA e-invoice stream (einvoices:read, financial tier)

const failing = await client.einvoiceSubmissions.list({ from, to, status: 'REJECTED' })

Never exposes XML or certificates — statuses and response messages only. einvoice.cleared's payload status field reads SUBMITTED (the CLEARED enum is never persisted — see Webhooks for why); einvoice.rejected carries a 200-character-truncated errorMessage.

What's explicitly deferred

Per docs/plugin-platform/api-expansion-roadmap.md, PATCH /claims/:id (letting a plugin drive a claim's review status directly) is scoped for Wave 3 but its implementation is deferred to the DHS branch — it did not ship in this PR despite claims being otherwise read-complete. Wave 4 (CRM, comms, loyalty, inventory, bookkeeping, BI integrations) is a written proposal only, not implemented here.