> ## 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.

# Syncing & incremental polling

> How Max AI mirrors EHR data, what `lastSyncedAt` guarantees, how rate limits are scoped, and the cost of backfills

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](/guides/pagination#delta-sync-and-cursorid). That is the
supported change feed, and this page does not restate it.

<Warning>
  **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.
</Warning>

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](/guides/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](/guides/webhooks) 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.
