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

# Appointment types & cancel reasons

> Reference catalogues for building appointment-type and cancellation-reason pickers, and how to filter appointments by them

Two reference endpoints back the pickers a scheduling or reactivation app needs:

* `GET /v4/appointment-types`
* `GET /v4/cancel-reasons`

Both are org-scoped, unpaginated (a practice configures tens of these, not
thousands), and require the `read:appointments` scope.

## Appointment types

```
GET /v4/appointment-types
```

```json theme={null}
{
  "success": true,
  "data": {
    "appointmentTypes": [
      {
        "id": "atp_9f1c2d3e4b5a46f78901234567890abc",
        "externalId": "6054",
        "instanceId": "ins_1a2b3c4d5e6f47a89b01c2d3e4f56789",
        "name": "Follow-up, established",
        "abbreviation": "EST",
        "durationMinutes": 15,
        "cosmetic": null,
        "active": true,
        "lastSyncedAt": "2026-07-31T04:15:00.000Z"
      }
    ]
  }
}
```

Pass `?inUseOnly=true` to get only the types that appear on at least one synced
appointment — useful when a picker should not offer a filter that would match
nothing.

Pass `?activeOnly=true` to get only the types the practice can still book.
Retired types are returned by default, because historical appointments still
reference them and a picker that cannot name one renders that appointment's type
as blank. **ModMed only** — EZDerm reports no status, so EZDerm types have
`active: null` and are excluded by this filter.

### Completeness differs by EHR

<Warning>
  |                             | ModMed                                            | EZDerm                                           |
  | --------------------------- | ------------------------------------------------- | ------------------------------------------------ |
  | Source                      | The practice's own appointment-type configuration | Types observed on synced appointments            |
  | Includes never-booked types | Yes                                               | **No**                                           |
  | `durationMinutes`           | The type's configured default                     | Null                                             |
  | `abbreviation`              | Present                                           | Null (EZDerm has no such field)                  |
  | `cosmetic`                  | Null (ModMed has no such field)                   | Present                                          |
  | `active`                    | Present (from ACTIVE / ALWAYS\_ACTIVE)            | **Null** — so `?activeOnly=true` returns nothing |

  For EZDerm, a type nobody has booked inside the sync window is absent from the
  catalogue. Treat the EZDerm list as a floor, not a complete configuration dump.
</Warning>

### Filtering appointments by type

```
GET /v4/appointments?appointmentTypeIds=atp_9f1c2d3e4b5a46f78901234567890abc
```

<Warning>
  **Filter on `id`, not `externalId`.**

  `externalId` is the EHR's own id, and ModMed hands out practice-local integers
  like `6054`. On an organization with two connected EHR instances the same
  `externalId` names two different types, so a filter keyed on it would return the
  other location's appointments too. `id` is Max AI's own id and is unique.

  Ids that no longer resolve are ignored. If **none** of the ids in a filter
  resolve, the result is **empty** — not unfiltered. That is deliberate: silently
  dropping a broken filter would turn "these three appointment types" into "every
  appointment in the practice".
</Warning>

On an appointment, `appointmentType.externalId` matches this catalogue's
`externalId`. For an organization with a single connected EHR instance — the
common case — that mapping is exact. With two instances, two catalogue rows can
share an `externalId`. The appointment carries `instanceId` for exactly this
reason: pair it with `appointmentType.externalId` and match against the
catalogue row's own `instanceId` + `externalId`. Filtering still takes the
catalogue `id`, which is unique on its own.

<Note>
  `lastSyncedAt` on these two catalogues is a **last-seen-at**, not a
  changed-at: it advances on every sync in which the type is still present in
  the practice's configuration, so you can tell a stale catalogue from a healthy
  one. A RETIRED type stops being observed, so its stamp freezes at the last sync
  that saw it — do not read `min(lastSyncedAt)` across the catalogue as connector
  staleness. That is the opposite of `lastSyncedAt` on an
  appointment, which only moves when the row's data actually changed. Do not
  use it to detect catalogue edits — compare `name` / `active` instead.
</Note>

## Cancellation reasons

```
GET /v4/cancel-reasons
```

```json theme={null}
{
  "success": true,
  "data": {
    "cancelReasons": [
      {
        "id": "crn_7e8d9c0b1a2f43e5678901234567890a",
        "externalId": "CR00000001",
        "instanceId": "ins_1a2b3c4d5e6f47a89b01c2d3e4f56789",
        "name": "Patient cancelled",
        "active": true,
        "predefined": true,
        "lastSyncedAt": "2026-07-31T04:15:00.000Z"
      }
    ]
  }
}
```

Retired reasons are returned by default — historical appointments still
reference them, and a picker that cannot name one would render that
appointment's reason blank. Pass `?activeOnly=true` for only the reasons the
practice can currently select.

Sourced from ModMed's cancel-reason configuration. **EZDerm exposes no
equivalent reference surface**, so an EZDerm-only organization gets an empty
list.

## Cancellation reasons on an appointment: current limitation

<Warning>
  **Today `cancelReason` is `null` on every scraped appointment.**

  Neither ModMed's scheduler feed nor EZDerm's encounter feed returns a
  cancellation reason when *reading* appointments — ModMed only accepts a reason
  on the *write* (cancel) call. So a `null` here means **"not reported by the
  EHR"**, not "cancelled for no reason".

  What this means for you:

  * You can configure which reasons should suppress outreach **now**, from
    `GET /cancel-reasons`. That configuration starts taking effect automatically
    the day a vendor read feed carries the value — no client change needed.
  * Do **not** write a suppression rule that treats `null` as a specific reason
    (e.g. "null means the patient cancelled"). It would misfire on every
    appointment today and change meaning later.
  * `?cancelReasonIds=` filters correctly, but currently matches nothing, for the
    same reason.
</Warning>
