REST API Reference

Full reference: the OpenAPI 3.1 document at packages/plugin-sdk/openapi/dentolize-v1.json (regenerate with yarn workspace @dentolize/server openapi:generate). Render it with any OpenAPI viewer; the published docs site uses a Scalar static build.

Conventions

Resources

Route Methods Scope
/ping GET any token
/installation GET settings:read
/installation/settings PATCH settings:write
/clinic GET clinic:read
/branches, /branches/{id} GET clinic:read
/reference/appointment-types, /reference/patient-tags, /reference/payment-types GET clinic:read
/practitioners GET practitioners:read (+.contact for phone/email fields)
/procedures GET procedures:read
/patients, /patients/{id} GET, POST, PATCH patients:read (+.pii for identity fields, patients:write for writes)
/patients/lookup GET (phone required — deterministic registered-or-new check; returns { registered, patientId, leadId }, ids only) patients:read
/appointments, /appointments/{id} GET, POST, PATCH, DELETE (cancel) appointments:read / appointments:write
/availability GET (branchId, doctorId, date range) availability:read
/invoices, /invoices/{id} GET invoices:read
/invoices POST (create cash visit invoice; Idempotency-Key required) invoices:write
/payments, /payments/{id} GET payments:read
/payments POST (record external payment; Idempotency-Key required) payments:write
/treatments, /treatments/{id} GET (incl. pre-auth filters approvalRequired/approved) treatments:read
/webhook-endpoints (+/{id}, /{id}/rotate-secret, /{id}/deliveries, /deliveries/{id}/redeliver, /{id}/test-event) CRUD webhooks:manage
/labs GET laborders:read
/lab-items GET (labId filter) laborders:read
/lab-orders, /lab-orders/{id} GET, PATCH laborders:read / laborders:write (PATCH accepts only received/delivered/shade/details)
/quotations, /quotations/{id} GET (from/to required on list) quotations:read
/prescriptions, /prescriptions/{id} GET prescriptions:read
/reference/medications GET prescriptions:read
/forms GET forms:read
/form-submissions, /form-submissions/{id} GET, POST forms:read / forms:write (answers on the detail route only; signature answers stripped)
/consent-forms GET forms:read (signed state only — never signature images)
/encounters, /encounters/{id} GET encounters:read (measurements on the detail route)
/encounters/{id}/measurements POST encounters:write (fires measurement.recorded)
/patients/{id}/files GET, POST files:read / files:write (POST = upload step 1)
/patients/{id}/xrays GET files:read
/patients/{id}/files/{fileId}/confirm POST files:write (upload step 2)
/insurance-companies GET (searchTerm filter) insurance:read
/insurance-companies/{id} GET insurance:read
/insurance-policies GET (insuranceCompanyId filter) insurance:read
/policy-classes GET (policyId filter) insurance:read
/claims GET (from/to required — window matches overlapping claim periods; status, insuranceCompanyId, branchId filters) claims:read
/claims/{id} GET (adds invoiceIds — member invoice ids only; amounts stay behind invoices:read) claims:read
/online-payments GET (from/to required; status, invoiceId, branchId filters) onlinepayments:read
/online-payments/{id} GET onlinepayments:read
/payment-links POST (generate hosted gateway link; Idempotency-Key required) paymentlinks:write
/einvoice-submissions GET (from/to required; status, invoiceId filters) einvoices:read
/einvoice-submissions/{id} GET einvoices:read
/leads GET (stageId, assignedToId filters), POST (create from campaign; Idempotency-Key required; duplicate phone → 409 lead_exists) leads:read / leads:write
/leads/{id} GET, PATCH (stage move w/ full pipeline semantics, score, assignee, deal size) leads:read / leads:write
/lead-stages GET leads:read
/messages POST (queue outbound WhatsApp-template/SMS message → 202 { communicationId, status: "queued" }; Idempotency-Key required) communications:send
/communications GET (patientId, channel filters; per-channel status + content preview only) communications:read
/conversations GET (resolved, assignedTo, patientId, phone filters — phone accepts any app format, normalized to the WhatsApp number) conversations:read
/conversations/{id} PATCH (assign / resolve) conversations:write
/conversations/{id}/messages GET, POST (free-text reply within the 24h window → 409 conversation_expired outside; 409 whatsapp_not_configured without official WA) conversations:read / conversations:write
/patients/{id}/points GET (points aggregate; zeros when none) loyalty:read
/points-transactions GET (patientId, action filters), POST (MANUAL earn/redeem — positive earns, negative redeems; 409 insufficient_points) loyalty:read / loyalty:write
/appointment-feedback GET (doctorId, patientId filters; rating + comment + per-question answers) feedback:read
/tasks GET (completed, dueFrom/dueTo, branchId filters), POST tasks:read / tasks:write
/tasks/{id}/complete PATCH (approval-required tasks stay pending approval; completing twice is a no-op) tasks:write
/inventory-items, /inventory-items/{id} GET (branchId, belowMin=true filters) inventory:read
/suppliers GET inventory:read
/inventory-orders GET (status, supplierId, branchId filters), POST (PURCHASE order in CREATED status; Idempotency-Key required) inventory:read / inventory:write
/inventory-orders/{id} GET (adds order lines items), PATCH (supplier-side status only: CONFIRMED / IN_TRANSIT; COMPLETED + CANCELED stay clinic-side → 403) inventory:read / inventory:write
/expenses GET (from/to required; mainType, branchId filters) bookkeeping:read
/incomes GET (from/to required; branchId filter) bookkeeping:read
/transactions GET (from/to required; treasuryId filter; carries balanceAfter) bookkeeping:read
/treasuries GET (branchId filter) bookkeeping:read
/analytics/revenue GET (from/to, branchId; invoiced/paid/pending + by payment type) analytics:read
/analytics/appointments GET (from/to, branchId; counts by status + no-show rate) analytics:read
/analytics/treatments GET (from/to; counts by status + top procedures) analytics:read
/exports POST (bulk NDJSON export: { resource, updatedAfter? } → 202; Idempotency-Key required; 413 above 50,000 rows) read scope of the exported resource
/exports/{id} GET (status + presigned download url once completed, 24h validity) read scope of the exported resource
/events GET (reconciliation feed of granted event types; type, from/to, cursor) webhooks:manage
/events/{id} GET (evt_… id without the prefix, or the raw row id) webhooks:manage

Bulk exports (POST /exports)

For initial sync/backfill instead of hammering paginated lists. Body: { resource, updatedAfter? } with resource one of patients | appointments | invoices | payments | treatments. Requires the read scope of the exported resource; serialized rows apply the same PII tiers as the list routes (e.g. patients without patients:read.pii export masked). Capped at 50,000 rows (413 export_too_large) — window larger datasets with updatedAfter. v1 runs the export inline and responds when the file is ready; poll GET /exports/{id} until status: "completed", then download the presigned NDJSON url (24h validity). The SDK wraps the poll: client.exports.waitFor(exportId, { intervalMs, timeoutMs }).

Events reconciliation (GET /events)

After webhook downtime, list the domain events you missed instead of re-syncing every resource — each row carries the same thin payload the webhook envelope would have delivered. Scoped to your installation's company, the token's mode, and the concrete event types granted at install time; ?type= filters to a single granted type (400 invalid_event_type for unknown, 403 event_not_granted for ungranted). Requires webhooks:manage.

Recording external payments (POST /payments)

For POS/gateway partners that collect money outside Dentolize. Body: { invoiceId, amount, type, treasuryId?, reference? } with type one of CARD | CASH | BANK_TRANSFER | MOBILE_WALLET | CHEQUE | OTHER (BALANCE and INSURANCE are rejected — those money flows are not reachable from the API). Runs the full platform money path: treasury routing via branch defaults, invoice/patient totals, payment.created / invoice.paid events. Idempotency-Key is required; the payment id is additionally derived from the key, so a retry can never record the money twice.

Status error.code Meaning
400 amount_exceeds_pending amount above the invoice pending amount — details.pendingAmount carries the max
400 treasury_not_available_for_branch treasuryId exists but is not usable for the invoice branch
404 invoice_not_found No invoice with this id in this clinic
404 treasury_not_found No treasury with this id in this clinic
409 invoice_already_paid The invoice has no pending amount
409 treasury_required The clinic mandates a treasury on every payment; pass treasuryId

Creating visit invoices (POST /invoices)

For agentic bots and partner apps closing a visit. Body: { patientId, branchId, items: [{ procedureId, price?, tooth?, discount? }], appointmentId?, doctorId?, discount? | discountPercent?, notes? } (1-50 items; tooth is FDI numbers and multiplies the line quantity; use only ONE discount mode — invoice discount, invoice discountPercent, or per-item discounts). doctorId defaults to the appointment's doctor, then the patient's doctor.

Runs the exact clinic-app path: each item becomes a completed treatment line (doctor commissions, inventory usage, package counters all behave identically) and the invoice goes through the platform money core — patient totals, notifications and the invoice.created event included. v1 is cash-only: insurance/claim fields are rejected by the strict body (400 invalid_request) and diagnostic-fee lines are not accepted.

Idempotency-Key is required; the invoice id and every treatment-line id are derived from it, so a retry after a partial failure resumes (already-created lines are reused, an already-created invoice is returned as-is) — never a double charge.

Status error.code Meaning
400 doctor_required No doctorId given and neither the appointment nor the patient has a default doctor
400 discount_exceeds_subtotal An invoice- or line-level discount exceeds its subtotal (details.subtotal, plus details.procedureId on line discounts)
400 invalid_request Body validation failed (e.g. insurance fields, mixed discount modes) — details lists the offending paths
404 patient_not_found No active patient with this id in this clinic (leads/disabled records are not invoiceable)
404 appointment_not_found / procedure_not_found Referenced record missing (details.procedureIds lists unknown procedures)
409 procedure_requires_signed_form The procedure requires a signed consent form — not completable via the API
409 procedure_requires_counter Package procedure requiring a counter — not invoiceable via the API
409 approval_required The clinic requires insurance pre-approval before completing this procedure
409 operation_already_invoiced A treatment line is already attached to another invoice

Patient lookup (GET /patients/lookup?phone=)

Deterministic registered-or-new check for bots: the phone is normalized exactly like the clinic app normalizes it (so any accepted input format matches) and exact-matched against the stored full number. Returns { data: { object: "patient_lookup", registered, patientId, leadId } }registered/patientId for an active patient, leadId (independently) when the phone matches a lead, letting you distinguish patient / known-lead / unknown. Ids only — no names or phones in the response, so plain patients:read suffices and no PII access is logged.

Payment links (POST /payment-links)

Body: { invoiceId, amount? } (amount defaults to the invoice pending amount). Creates a hosted payment page on the clinic's configured gateway — its default payment option, or its sole one — and returns 201 with the created online payment (paymentLink is the URL to share). Watch onlinepayment.succeeded / .failed / .refunded for the outcome; on success paymentId links the recorded payment. Idempotency-Key is required.

Status error.code Meaning
400 amount_exceeds_pending amount above the invoice pending amount (details.pendingAmount)
404 invoice_not_found No invoice with this id in this clinic
409 invoice_already_paid The invoice has no pending amount
409 payment_provider_not_configured No usable online payment option (none configured, or several without a default)
502 payment_provider_error The gateway rejected the link request (provider internals are never leaked)

File uploads (two-step)

Uploading a document or x-ray to a patient record is a two-step flow. Allowed content types: image/jpeg, image/png, image/webp, image/tiff, image/bmp, application/pdf, application/dicom; max 100 MB (sizeBytes must be exact).

  1. POST /patients/{id}/files with { fileName, contentType, sizeBytes, kind: "file" | "xray", operationId? }{ data: { fileId, uploadUrl, fields, method: "POST", expiresInSeconds } } — a presigned S3 POST.
  2. Multipart-POST every fields entry plus the file (as the last field, named file) to uploadUrl — no Dentolize auth headers on this request.
  3. POST /patients/{id}/files/{fileId}/confirm → the File row (and an Xray row when kind: "xray"), firing file.created / xray.created.
presign=$(curl -s -X POST "$BASE/patients/$PATIENT/files" \
  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: up-$(uuidgen)" \
  -H 'Content-Type: application/json' \
  -d '{"fileName":"pano.png","contentType":"image/png","sizeBytes":102400,"kind":"xray"}')
# multipart-POST fields + file to the presigned URL (fields first, file last)
curl -s "$(jq -r .data.uploadUrl <<<"$presign")" \
  $(jq -r '.data.fields | to_entries[] | "-F \(.key)=\(.value)"' <<<"$presign") \
  -F "file=@pano.png"
curl -s -X POST "$BASE/patients/$PATIENT/files/$(jq -r .data.fileId <<<"$presign")/confirm" \
  -H "Authorization: Bearer $TOKEN" -H "Idempotency-Key: confirm-$(uuidgen)"

The SDK wraps all three steps:

const file = await client.patientFiles.upload({
  patientId,
  buffer: await readFile('pano.png'),
  fileName: 'pano.png',
  contentType: 'image/png',
  kind: 'xray'
});