Skip to main content
patients is the account’s core record — practitioners, appointments, invoices and files all hang off a patient_id. Every write runs through PatientService, the same code PracticeHub’s own patient file uses, so a patient created by an integration is held to exactly the rules a receptionist is.
numbers[] and address are separate tables under the hood (numbers, address), but they are not separate resources to write — you write them nested inside a patients create or update, and they come back nested on every read. GET /numbers and GET /addresses exist for read-only bulk queries; the contract lives here.

Creating a patient

Only first_name and last_name are required — the same minimum every patient form in PracticeHub enforces:

Contact details: numbers and address

Every phone number needs a type — mobile, home, work or other (matched case-insensitively on input, always lowercase on output). A number sent with no type is refused (422 phone_type_required); a type outside that list is not refused, it’s just stored as Mobile — historic data and free-text imports carry values PracticeHub itself never generates, and the write path doesn’t punish that. Numbers are normalised on the way in: country_code is stored without its leading +, iso2 is lower-cased, and an E.164 intl_number is derived automatically when you don’t supply one — send number and iso2 (or a number that already has a +countrycode prefix) and the API works out the rest. An unparsable number is still stored, with a best-effort intl_number — never refused, matching what the patient-file phone widget does.
reads back as:
The address object accepts line_1, line_2, city, state, postal_code, country — all optional, all ≤ 255 chars.

Updating numbers: read the id back first

PATCH reconciles numbers[] by id, the same pattern invoices.line_items[] uses:
  • An entry with the id you read back is updated in place.
  • An entry with no id is added.
  • A stored number you leave out entirely is removed.
This keeps number 5012 (with a new digit string) and adds a new Work number; any third number that existed on the patient and isn’t listed here is deleted. Sending an id that isn’t one of this patient’s numbers is a 422 phone_number_not_found — read the patient first rather than guessing ids.
A list with no ids at all still works — it replaces the whole set, but only writes anything if the new list actually differs from what’s stored, so re-sending an unchanged list is a safe no-op for simple clients that don’t want to track ids.
address is a true partial update: PATCH with only { "address": { "postal_code": "N1 9GH" } } changes the postcode and leaves every other line alone — unlike numbers[], there’s no reconciliation to think about, because there’s only ever one address row per patient.

Tags

Every patient read carries the file’s current tags:
Only active tags of the account are listed, each once. id is the tag (GET /tags lists the account’s tags — id, name, color, sort_order); patient_tag_id is this tag on this file. editable: false means a care plan put the tag there — the plan takes it off when it ends. Tags go on and come off through their own write-only resource (POST and DELETE; there is no GET /patient_tags — read a file’s tags here), never through a patient write (tags on a POST/PATCH /patients is a 422):
tag_id must be an active tag of the account (422 tag_not_found) and patient_id a live patient (422 patient_not_found). Putting on a tag the file already holds returns the existing row rather than a second one. Taking off a tag a care plan manages is refused (409 tag_locked). Either change announces patients.updated to your webhooks.

Referral sources

referral_source_id and referral_source_type travel together — send both or neither. referral_source_type is referral_source (a source from GET /referral_sources, must be active) or patient (another patient on the account, must be live and not this patient — 422 referral_self if it is).
PracticeHub’s public intake forms can record a patient referral by name alone, for staff to match to a real patient record later — that “referred by Jane, unmatched” state exists because a public form has no patient list to pick from. An API integration does have one: a patient referral written through the API must always name the referring patient’s id. referral_source_type: "patient" with no referral_source_id is refused (422 referral_patient_required), not left half-filled. Changing an unrelated field doesn’t force you to re-validate a referral you didn’t touch — a reference the update leaves alone is kept even if the referral source has since been deactivated.

Communication preferences

Only two notification toggles are exposed as patient attributes today, nested under communication_preferences:
Marketing email/SMS opt-in, the reminder/confirmation channel and other comms settings are account defaults and app-level preferences, not attributes this resource writes — GET /schema/patients is the source of truth for exactly what’s accepted.

Blocking online booking

blocked: true is the patient file’s own Block Online Booking toggle — it records that this patient shouldn’t be offered online self-booking. It is a plain flag on the record, not a rule this resource enforces: nothing in POST /appointments checks it, so a booking made through this API for a blocked patient succeeds exactly as it would for any other patient.

Duplicate strategy

There’s no built-in duplicate check on POST /patients — the same patient sent twice creates two records, so dedupe before you write:
  • Migrating from another system? Use the legacy-ID pattern — a metadata.legacy_id and a GET /patients?metadata[legacy_id]=eq:… lookup before every create. This is the recommended approach whenever you control the identifier.
  • No stable external id? Query by the attributes that are actually filterable on this resource — email or name:
Neither is a guarantee — patients share names, and email is optional — so treat a match as a candidate to confirm (against dob, phone) rather than an automatic merge.

Deletion semantics

DELETE /patients/{id} is the same soft delete the patient file’s own delete action performs, in one transaction:
  1. The patient row is flagged deleted (not removed) and its mobile-app login is disconnected.
  2. Every recall for the patient is deactivated.
  3. Every future pending appointment for the patient is deleted too — the same rule appointment delete applies, so a pending visit that’s already carrying an active invoice (a late-reschedule fee, say) is left behind rather than silently dropped.
  4. A patient-log entry records the deletion, and the patient is removed from search.
A snapshot — first_name, the last name’s first initial, and default_location_id — lands in deleted_entities before the flag is set, so a sync process that reads deleted_entities can still tell who a deleted id used to be. Deleting an already-deleted patient is a 404, not a no-op success. See Writing for the full field-by-field contract and error codes, and Data migration for importing patients in bulk.