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 database inside the write transaction, before commit. Keyset pagination prevents offset shifts and resolves timestamp ties; it does not make the feed commit-ordered. A write whose transaction commits after you read past its stamp lands behind your cursor, even when you use cursorId. The gap is bounded by the length of one sync batch transaction (normally well under a second, longer under lock contention), so pin each walk with an updatedUntil a few minutes in the past and start the next walk from an overlap before it, applying rows idempotently by id. Keep periodic reconciliation for anything outside that bound. Webhook updatedAt uses the same database clock.
  • A whole-clinic walk is a set of windows, not one pass. Fix updatedUntil for the walk and advance updatedSince + cursorId from the last row. A row that stays in the window cannot be skipped. A row re-synced during the walk gets a stamp past updatedUntil and leaves the window; it is in the next window instead. The window is done when a page comes back shorter than pageSize. Do not prove completeness by comparing distinct rows against totalCount: on a keyset page it counts from the current updatedSince, so it falls as you walk, and rows that left the window are not in it.
  • For one patient’s complete history, use POST /v4/patients/history instead of filtering a clinic-wide feed: it reads every chart from one snapshot and says whether an empty history is complete or not yet imported.

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. For a replayable, commit-ordered record of every patient, identity, appointment and bill change, read the change feed from your stored cursor; its changes.available webhook is only a wake-up. 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.

Linking appointments, bills and payments

Every bill carries the EHR’s own link to the appointment it was generated from and to the visit note it was billed from — no patient-plus-date matching:
  • bill.appointmentId / bill.appointmentExternalId — the appointment. ?appointmentId= filters /bills to one appointment’s bills.
  • bill.encounterId / bill.encounterExternalId — the note, as /v4/notes identifies it. Several bills can share one (professional + facility). ?encounterId= accepts any version id of the note.
  • appointment.billIds[] / appointment.encounterId — the same links from the appointment side (archived and not-yet-synced bills excluded).
  • GET /bills/{id}?include=payments — dated money received against the bill: payments as applied to its charges, plus payer remittances not applied yet. Refunds and reversals are negative. See the payments field for the known EZDerm gaps.
Links fill in as bills are re-read. A newly filled link bumps the bill’s updatedAt, so it reaches /bills?updatedSince consumers; it does NOT move the appointment’s lastSyncedAt. Keep the join on the bills feed: mirror bill.appointmentId and join locally, rather than re-reading appointments to discover new billIds. The first rollout fills links for historical bills in a backfill, which re-delivers those bills once on the /bills delta feed. Dedupe on id as usual. appointment.bookingOrigin is ModMed’s own booking origin, verbatim. ModMed reports it only on the bill, so it is null until the visit has been billed (and always null on EZDerm) — it describes a past visit, not a fresh booking.

Mirror lag

Appointment syncs run on a five-minute cadence per connector and cover a window of one week back plus thirteen 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.