> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Appointments

> The appointment lifecycle end to end — booking vs recording, statuses, groups, resources and rescheduling

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.

<Info>
  `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](/guides/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.
</Info>

## Who the visit is for

Every appointment read embeds the patient, so a calendar built from the API needs no second call per row:

```json theme={null}
"patient": { "id": 340, "first_name": "Augusta", "last_name": "Lovelace", "preferred_name": "Ada", "name": "Ada Lovelace" }
```

`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

```mermaid theme={null}
stateDiagram-v2
    [*] --> pending: POST /appointments
    pending --> arrived: PATCH status=arrived
    arrived --> pending: PATCH status=pending (un-arrive)
    pending --> cancelled: PATCH status=cancelled
    pending --> missed: PATCH status=missed
    arrived --> cancelled: PATCH status=cancelled
    arrived --> missed: PATCH status=missed
    cancelled --> pending: PATCH status=pending (reinstate)
    missed --> pending: PATCH status=pending (reinstate)
    pending --> processed: in-app checkout only
    arrived --> processed: in-app checkout only
    processed --> pending: in-app "unprocess" / void invoice only
```

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:

| Intent | Do this |
| - | - |
| **Book a real slot for a patient right now** | [`GET /availability`](/guides/booking) first, copy `starts_at`/`ends_at`/`practitioner_id`/`resource_id` from a slot it returned, then `POST` — see [Booking](/guides/booking) |
| **Record a visit that already happened** (data migration, backdated entry) | `POST` directly with the historical `starts_at` — there's nothing to check availability against for the past. See [Data migration](/guides/data-migration#2-appointment-history) |

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

```bash theme={null}
curl -X POST $BASE/appointments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "location_id": 81, "appointment_type_id": 1, "practitioner_id": 109, "starts_at": "2026-09-01T09:00:00" }'
```

| Field | Required | Notes |
| - | - | - |
| `patient_id`, `location_id`, `appointment_type_id`, `practitioner_id` | Yes | Each must exist on the account — `422 patient_not_found` / `location_not_found` / `appointment_type_not_found` / `practitioner_not_found` |
| `starts_at` | Yes | `YYYY-MM-DDTHH:MM:SS`, clinic timezone |
| `ends_at` | No | Omit it and it's derived exactly as the calendar does: the **practitioner's** configured duration for that type, else the **type's own** duration, else **30 minutes**. Send it and it's used as-is — it just has to land after `starts_at` |
| `note` | No | ≤ 1000 chars |
| `resource_id` | Conditional | See [Resource-view locations](#resource-view-locations) below |

## 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.

```bash theme={null}
curl -s "$BASE/resources?location_id=81" -H "Authorization: Bearer $KEY"
```

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:

```bash theme={null}
curl -X POST $BASE/appointments \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "patient_id": 340, "group_master_id": 6210 }'
```

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.

<Note>
  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.
</Note>

## Cancelling and marking missed

```bash theme={null}
curl -X PATCH $BASE/appointments/6350 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "status": "cancelled", "cancel_reason_id": 4, "cancel_note": "Patient rescheduling for next week" }'
```

`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.

```bash theme={null}
# Simple move: no availability check, same as dragging it in the calendar
curl -X PATCH $BASE/appointments/6350 \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{ "starts_at": "2026-09-02T10:00:00" }'
```

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](/guides/booking#parameters).

`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:

```bash theme={null}
curl -g "$BASE/appointments?patient_id=eq:340&starts_at=gte:2026-09-01T00:00:00&starts_at=lt:2026-09-02T00:00:00&status=in:pending,arrived,processed" \
  -H "Authorization: Bearer $KEY"
```

## 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.

```bash theme={null}
curl -X DELETE $BASE/appointments/6350 -H "Authorization: Bearer $KEY"
```

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](/guides/writing#the-apps-rules-apply) for the full field-by-field contract and error codes, [Booking](/guides/booking) for finding and taking a genuinely free slot, and [Data migration](/guides/data-migration#2-appointment-history) for importing appointment history in bulk.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.