Max AI reads from a mirror of the practice’s EHR, refreshed by per-connector
sync tasks. Nothing you read is a live call into the EHR, so every read is
subject to mirror lag. This page is about building a poller that copes with that.
Polling for changes
Use updatedSince with cursorId on /appointments — the keyset walk described
in Pagination. That is the
supported change feed, and this page does not restate it.
Do not walk a delta feed with page/offset. Rows are ordered by the very
stamp the sync workers rewrite, so a row that changes mid-walk moves to the end,
everything after it shifts one position forward, and your next offset steps over
whichever row slid into that slot. That row is never returned, and its stamp is
below the cursor your walk finishes on, so no later poll returns it either — a
cancelled appointment can be silently skipped while the walk reports success.
Sorting by lastSyncedAt and paging by offset has this bug; an overlap window
does not fix it, because the skipped row’s stamp is older than your cursor,
not newer.
What the stamp itself guarantees:
lastSyncedAt advances only when the row’s data genuinely changed. The
appointments upsert is guarded by a no-op predicate covering every column it
writes, so the timestamp is a changed-at, not a last-seen-at, and a quiet
appointment keeps its old value indefinitely. The reference catalogues
(/appointment-types, /cancel-reasons) deliberately do the opposite: their
lastSyncedAt is a last-seen-at: it advances on every sync that still
observes the row, so a stale catalogue is distinguishable from a healthy one.
A retired row stops being observed and its stamp freezes there. See
Appointment reference data.
- It is stamped by the writing worker before commit. Keyset pagination
prevents offset shifts and resolves timestamp ties; it does not make the feed
commit-ordered. A delayed write or a worker with a slower clock can commit a
change behind a cursor you already passed, even when you use
cursorId.
Between polling runs, replay an overlap window and apply rows idempotently.
Choose that window to cover expected clock skew and in-flight writes; it is a
bounded mitigation, not a lossless guarantee. Keep periodic full reconciliation
for changes outside that bound. Within each replay window, use keyset pagination
rather than offset pagination. Webhook updatedAt uses the database clock and
is a separate timestamp.
Webhooks
Appointments now emit appointment.created, appointment.updated,
appointment.cancelled and appointment.completed to apps holding the
read:appointments scope, so cancellations, no-shows, reschedules and new
bookings all arrive without polling for them. See the
webhooks guide for the payloads and the full event list.
Still build reconciliation around polling. Delivery is at-least-once and
unordered, and an event can be lost outright — the events are a latency
improvement, not a source of truth. Poll on a slower cadence than you would
without them and treat any disagreement as the poll being right.
Rate limits
1,000 requests per minute, per app, across every organization that has
installed it — a sliding window keyed on your app id alone, not on
(app, organization).
This matters for multi-location clinics and for any app installed by more than
one practice: a full page-through for one large organization consumes budget that
every other organization’s sync shares. Pace accordingly — spread backfills, and
prefer a steady request rate over bursts.
Backfill cost
GET /bills already embeds codingGroups[], each carrying diagnoses[] and
lineItems[], and on v4 it also nests the patient. A backfill does NOT need a
detail call per bill to read codes, and GET /bills/{id} returns the same
codingGroups shape the list row does.
Before paging 40,000 bills twice, diff the two response schemas for the version
you are on — ExternalBillResponse vs ExternalBillDetailResponse in the
OpenAPI document — and pull the detail only for the fields that are genuinely
absent from the list row. That delta differs by version and this guide will not
track it faithfully; the spec will. At 1,000 requests/minute per app, 40,000
needless detail calls is ~40 minutes of a budget every organization on your app
shares.
At the 1,000/min ceiling that is ~60k bill details per hour of continuous
polling. For a multi-year backfill:
- Run it once, persist the codes your side, and switch to incremental afterwards.
- Bound it by date range rather than fetching all history at once.
- Keep it off the same minute-budget as your live sync if you can — a backfill
that starves the incremental poller makes your attribution stale exactly when
it matters.
Mirror lag
Appointment syncs run on a five-minute cadence per connector and cover a
window of one week back plus six weeks ahead. An appointment
created in the EHR moments ago will not be in the mirror until the next pass, and
one scheduled beyond the sync window is not mirrored at all.