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
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
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.
Filtering appointments by type
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”.
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.
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.
Cancellation reasons
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
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.