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.
Payment links (paymentlinks:write, financial tier)
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.