GET /schema (which is the byte-for-byte contract for any one resource).
Ids and tenancy
Every id —id, and every *_id foreign key — is a plain integer, scoped to your account. Every underlying table carries a client column (clientID / client_id) that every query is filtered on; an id from another clinic’s account simply doesn’t exist as far as your key is concerned, and referencing one returns 404 or the relevant *_not_found code, never another account’s record. There is no cross-account id space to guess at.
The shape of it
A few relationships worth being explicit about:appointmentspoint atpatient_id,practitioner_id,location_idandappointment_type_id— all required to book — plus an optionalresource_id(required only when the location is resource-view,locations.calendar_view = 1) andgroup_master_idfor a group session attendee. See Writing → Appointments.invoicesbelong to apatient_idand optionally anappointment_id,practitioner_idandlocation_id; every invoice owns one or moreline_items, each pointing at abillable_item_id(a service/product from PracticeHub’s catalogue) and aprovider_id.paymentsbelong to apatient_idand apayment_method_id(a row inpayment_methods, or one of the account’s negative-id special types — credit, write-off).care_plansbelong to apatient_id(the account holder) and carry three embedded views:billing(how the plan is paid for — type, interval,total_price),schedules[](one visit schedule per patient on the plan) andmembers[](every patient sharing it). Read-only: a plan’s appointments are ordinaryappointmentsand its charges ordinaryinvoices.payment_allocationsare the join between the two ledgers: each one ties onepayment_idto oneinvoice_idfor anamount. The request’spatient_idmust be the invoice’s owner — but the payment may belong to a different patient, deliberately: family accounts and group care plans settle one member’s invoice from another’s payment, and both patients’ balances are refreshed. See Billing → Allocations.filesattach to apatient_id; a file offile_type: patient_logadditionally carriespatient_log_id, pointing at one of that patient’s own log entries.- Reference data —
practitioners,locations,appointment_types,modalities,resources,payment_methods,referral_sources,billable_items— is what appointments, invoices and payments point at. It’s read-only (or no-delete) over the API today; see Resources for exactly which.
Patients embed their contact details on read
numbers and addresses are resources in their own right (read-only as such — see Resources), but every GET /patients and GET /patients/{id} embeds them directly:
numbers[] or address, PATCH it back. Numbers are reconciled by id — send the id you read back to keep or update an entry, omit it to add one, leave a stored one out to remove it. address is a single object (one address per patient on the API); sending specific keys changes only those keys. Both are loaded per page, not per row, so listing patients doesn’t cost a query per patient. Full write rules: Writing → Patients.
The ledger is derived, not entered
invoices.balance, invoices.patient_balance, invoices.third_party_balance and the equivalent balances on patients are computed by PracticeHub’s billing service from the invoice’s line items and whatever has been allocated against it from payments via payment_allocations — the same arithmetic the app’s own invoice screen runs. They are ordinary attributes you can read and filter and sort on, but no write path accepts them as input: POST /invoices and PATCH /invoices/{id} take line_items, not a balance, and POST /payment_allocations is how a payment gets applied. There is no way to set a balance directly, on create or update — you supply the facts (lines, payments, allocations) and the ledger is always the result of those facts, never a value you assert.
This is also why data migrations import in a fixed order — patients, then appointments, then invoices, then payments, then allocations — so that by the time an old balance needs to reappear, it does so as an emergent property of the records that produced it. See Data migration → Import order.
deleted_entities: the tombstone feed
Records deleted in PracticeHub — through the API, the app, or a background job — disappear from their own resource, but a row survives in deleted_entities: entity_type, entity_id, patient_id (when applicable), and created_at / updated_at (the deleted record’s own original stamps) and deleted_at (when it was deleted). It’s read-only and exists for exactly one job: let a sync ask “what has disappeared since I last looked” with GET /deleted_entities?deleted_at=gte:{last sync} instead of diffing full listings. See Querying → Deleted records.
metadata on everything
Every resource carries a metadata object — your own identifiers, up to 50 keys, filterable with the same operators as any other attribute (metadata[key]=eq:value). It’s how you attach a CRM contact id, a legacy record id, or a sync cursor to a PracticeHub record without keeping a separate mapping table. Full details: Metadata.