Skip to main content
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.