Skip to main content
An appointment moves through a small, deliberate set of states. Every write — from the calendar, the public API, or a migration script — goes through the same AppointmentService, so the state a visit is in decides what you’re allowed to do to it next, regardless of which channel is asking.
POST /appointments never checks the practitioner’s diary — it books what you send, exactly as a staff member clicking a time in the calendar would. Booking is the separate, availability-checked flow for finding a genuinely free slot first. This page covers the appointment record itself: its lifecycle, its shape, and the rules around groups and resources.

Who the visit is for

Every appointment read embeds the patient, so a calendar built from the API needs no second call per row:
name is the name PracticeHub itself shows — the preferred name when the patient has one, otherwise the first name, followed by the last name. patient_id stays on the record for filtering (patient_id=eq:340); patient is read-only and not accepted on a write.

Lifecycle

Four statuses are settable through the API: pending, arrived, cancelled, missed. Sending status: "pending" means different things depending on where the appointment currently is — it reinstates a cancelled or missed visit, or un-arrives an arrived one; there’s no separate “unarrive” endpoint to remember. processed is not one of them. It means “billed through the app’s own checkout flow” — a real invoice and/or payment may already hang off the visit, so nothing outside that flow is allowed to set or clear it. Two guards enforce this everywhere:
  • Once processed, nothing moves it — not a reschedule, not a retype, not a cancel. Every one of those refuses with 409 appointment_rule until the visit is unprocessed (or its invoice voided) back in the app. note is the exception — it stays editable in any state, since it’s bookkeeping rather than scheduling.
  • A cancelled or missed visit is equally frozen for everything except reinstating it — you can’t arrive a cancelled visit or move a missed one; pending first, then the change you actually wanted.

Booking vs. recording history

The same POST /appointments serves two different intents, and the difference is process, not a different endpoint: Every new appointment is created pending, whichever intent it serves — for history, follow the create with a PATCH to the status the historical record actually had (arrived, cancelled or missed).

Required fields and how ends_at is derived

Resource-view locations

GET /locations returns calendar_view: 1 means a resource-view calendar — appointments sit in a room, table or bench, and every booking there needs a real resource_id from GET /resources?location_id=…. 2 means practitioner-view — the practitioner’s own column is the booking, and resource_id should be left out entirely.
Get this wrong and it’s a 422 resource_unavailable — missing on a resource-view location, or naming a resource that’s inactive or belongs to a different location. resource_id is also accepted on update, for moving an appointment to a different room without touching its time.

Group appointments

A group session is one master appointment (the session itself — time, place, practitioner, capacity) with attendee rows hanging off it via group_master_id. Joining one is the second request shape POST /appointments accepts:
The attendee’s location_id, practitioner_id, resource_id, starts_at and ends_at are always the master’s — sending any of them alongside group_master_id is refused rather than silently ignored, since they’d imply a session other than the one you named. appointment_type_id is the one field you may still send: it overrides the master’s type for that attendee, useful when a group session bills different attendees under different service types. Capacity is a domain rule, not a client-side check. The limit is the master’s own max_attendee_limit if set, else the appointment type’s group_max_attendees; no limit set on either means unlimited. It’s counted live against attendees whose status is pending, arrived or processed — a cancelled or missed attendee frees the place immediately. A full session refuses joins with 409 group_full; a group_master_id that isn’t an existing group-type session is 422 group_not_found. To leave a session, treat the attendee row like any other appointment and cancel it — PATCH the attendee’s own id with status: "cancelled". That’s the same code path as cancelling any appointment, and it updates the group’s attendee count as part of the same transaction.
A group master is created the same way any appointment is: patient_id: 0 against an appointment_type_id whose type is group, with the usual location_id/practitioner_id/starts_at. In practice, group sessions are almost always set up from the calendar’s own recurring-series tools — the API’s role here is joining an existing one, not standing up new series.

Cancelling and marking missed

cancel_reason_id and cancel_note (≤ 1000 chars) apply to both cancelled and missed. cancel_reason_id is not cross-checked against your account’s list of cancellation reasons — an id that doesn’t resolve to one just ends up with no reason name attached in the log, rather than being refused — and the list itself isn’t exposed as a resource yet, so confirm the ids you intend to use from Settings before you start sending them at scale, or lean on the free-text cancel_note instead.

Rescheduling patterns

Two different PATCH shapes both count as “moving” an appointment (anything touching starts_at, ends_at, practitioner_id, resource_id or location_id), and they behave differently when you omit ends_at:
  • On create, an omitted ends_at is derived fresh from the practitioner/type duration.
  • On update, an omitted ends_at preserves the appointment’s current duration and shifts it to the new starts_at — a 45-minute visit moved to a new time is still 45 minutes, not recomputed against the type’s default.
For a move that should respect the diary, use the availability engine’s reschedule mode instead of guessing: GET /availability?...&rescheduled_appointment_id=6350 treats the appointment’s own current slot as free (so its own duration and column width are accounted for correctly) and won’t offer its exact current start back to you as an “alternative” — then PATCH with the chosen slot’s fields, same as a fresh booking. See Booking. practitioner_id can’t be sent as null on update — an appointment can’t be unassigned, only moved to someone else — and only a reference you’re actually changing is re-validated: an appointment kept on a practitioner or resource that’s since been deactivated can still be dragged to a new time in the same column. Changing appointment_type_id on PATCH retypes the visit through the same guarded path as any other schedule change — it’s refused on a processed, cancelled or missed visit exactly like a move would be.

Same-day duplicate advisory

Unlike the calendar sheet and the auto-book/Book Schedule tools — which warn staff when a patient already has a pending, arrived or processed visit that day — POST /appointments does not carry any such check. A script that retries, or two integrations booking the same patient independently, can land two visits on the same day without either request being refused. If that matters to your integration, check first:

Deleting an appointment

DELETE /appointments/{id} is a permanent hard delete — only a deleted_entities audit row survives, there is no soft-delete or trash to recover from. Only a visit that’s still pending or arrived, with no active invoice against it, can go this way:
  • A processed, cancelled or missed visit refuses with 409 appointment_rule — reinstate or unprocess it first.
  • A pending or arrived visit that’s already carrying an active invoice (a late-cancellation fee, say) refuses too — void the invoice first.
The deleted_entities snapshot for an appointment carries appointment_type_id, location_id, practitioner_id, starts_at and ends_at — enough for a sync process to know what disappeared without having kept its own copy. See Writing for the full field-by-field contract and error codes, Booking for finding and taking a genuinely free slot, and Data migration for importing appointment history in bulk.