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
- Base URL:
https://<host>/api/v1; auth viaAuthorization: Bearer dtz_…. - Detail/write responses wrap the resource:
{ "data": { … } }. The SDK unwraps them. - List responses:
{ "data": [...], "hasMore": bool, "nextCursor": "…" }; page with?cursor=&limit=(limit ≤ 100, default 25). - Errors:
{ "error": { "code", "message", "requestId", "details?" } }. QuoterequestId(also in theX-Request-Idheader) in support requests. POSTrequires anIdempotency-Keyheader (≤255 chars). Replays within 24h return the stored response withIdempotent-Replay: true; concurrent duplicates get409 idempotency_conflict.- Financial lists (
/invoices,/payments,/quotations,/claims,/online-payments,/einvoice-submissions,/expenses,/incomes,/transactions) require afrom/torange of at most 366 days. - Core list endpoints accept
?updatedAfter=<ISO date-time>for delta syncs (records updated strictly after that instant). - Responses carry
Dentolize-Version(current2026-07).
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).
POST /patients/{id}/fileswith{ fileName, contentType, sizeBytes, kind: "file" | "xray", operationId? }→{ data: { fileId, uploadUrl, fields, method: "POST", expiresInSeconds } }— a presigned S3 POST.- Multipart-POST every
fieldsentry plus the file (as the last field, namedfile) touploadUrl— no Dentolize auth headers on this request. POST /patients/{id}/files/{fileId}/confirm→ the File row (and an Xray row whenkind: "xray"), firingfile.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'
});