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

# Patient history & readiness

> Read complete, snapshot-consistent appointment and bill histories for a group of charts, prove a linked group's history complete on its own, and tell an empty history from one that is not imported yet

`POST /v4/patients/history` reads the appointment and bill history of up to 25
charts at once. It exists for apps that decide something from a patient's
*whole* history — a recall campaign, a gap-in-care check — where a missed row is
a wrong decision, and where waiting for a clinic-wide import to finish is not an
option.

Scopes: `read:patients`, plus `read:appointments` and/or `read:bills` for the
resources you ask for.

The recipe for one linked group (one person's EZDerm and ModMed charts):

1. `POST /v4/patients/history/refresh` with the group's EZDerm charts (optional,
   see [Per-patient refresh](#per-patient-refresh)).
2. `POST /v4/patients/history` with **every** chart in the group and the
   `coverage` window you care about.
3. Act only when `groups[].appointments.status` is `complete`. Otherwise look at
   each chart's `readiness` to see what is missing.
4. Stay current from [`GET /v4/changes`](https://docs.maxcare.ai/guides/changes): a new booking on
   either chart arrives as `appointment.changed`; re-read that group.

## Request

```json theme={null}
POST /v4/patients/history
{
  "patientIds": ["pat_c56103bcd39c46d39f3138dd2b5e05f6", "pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a"],
  "resources": ["appointments", "bills"],
  "coverage": { "from": "2024-10-01", "to": "2026-10-06" },
  "limit": 100,
  "cursors": []
}
```

| Field | Rules |
| - | - |
| `patientIds` | 1–25 unique chart ids (`pat_…` or UUID). |
| `resources` | `appointments`, `bills`, or both (default). |
| `coverage` | Facility-local days appointment `readiness` is judged over. Default: the last two years through today (UTC). At most 3,660 days. |
| `limit` | Rows per chart per resource, 1–200, default 100. Lowered so one response holds at most 2,500 rows; the applied value is `limitPerResource`. |
| `cursors` | `nextCursor` values from earlier responses. Each names its own chart and resource. |

Unknown fields are a **400**, not ignored. A cursor issued to another
organization, malformed, repeated, or naming a chart or resource not in this
request is a 400.

## Response

```json theme={null}
{
  "code": "success",
  "data": {
    "snapshotAt": "2026-10-06T04:30:00.000Z",
    "limitPerResource": 100,
    "patients": [
      {
        "patientId": "pat_c56103bcd39c46d39f3138dd2b5e05f6",
        "found": true,
        "chart": {
          "id": "pat_c56103bcd39c46d39f3138dd2b5e05f6",
          "instanceId": "ins_0b6f2a1c9d8e4f7a8b6c5d4e3f2a1b0c",
          "ehrType": "modmed",
          "ehrPatientId": "112233",
          "mrn": "MM-100",
          "updatedAt": "2026-10-01T14:03:11.412Z",
          "masterPatientId": "mpt_4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a",
          "duplicatePatientIds": ["pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a"],
          "identityLinks": [{ "patientId": "pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a", "basis": "name_dob" }],
          "identityReview": { "status": "linked", "candidates": [] }
        },
        "appointments": {
          "status": "ok",
          "version": "9b2f…",
          "totalCount": 3,
          "hasMore": false,
          "nextCursor": null,
          "readiness": {
            "status": "complete",
            "basis": "window",
            "requestedFrom": "2024-10-01",
            "requestedTo": "2026-10-06",
            "asOf": "2026-09-30T02:10:00.000Z",
            "liveAsOf": "2026-10-06T04:25:00.000Z",
            "excludes": [],
            "scopes": [
              { "scope": "fac_…", "status": "complete", "verifiedDays": 736, "totalDays": 736,
                "firstUnverifiedDay": null, "lastUnverifiedDay": null,
                "asOf": "2026-09-30T02:10:00.000Z", "liveAsOf": "2026-10-06T04:25:00.000Z",
                "lastFailureAt": null, "lastFailureReason": null }
            ],
            "patientRefresh": null
          },
          "items": [
            { "id": "apt_…", "ehrAppointmentId": "778899", "status": "scheduled", "integrationStatus": "PENDING",
              "scheduledStartDate": "2026-10-20T13:00:00.000Z", "scheduledEndDate": "2026-10-20T13:15:00.000Z",
              "facilityId": "fac_…", "ehrFacilityId": "2706", "providerId": "prv_…", "appointmentTypeName": "Follow up",
              "isNewPatient": false, "isCosmetic": false,
              "createdAt": "2026-10-05T18:00:00.000Z", "lastSyncedAt": "2026-10-05T18:00:00.000Z",
              "removedFromEhrAt": null }
          ]
        },
        "bills": {
          "status": "ok", "version": "41ac…", "totalCount": 12, "hasMore": false, "nextCursor": null,
          "readiness": { "status": "unverified", "pendingHydration": 0 },
          "items": [ "…" ]
        }
      },
      { "patientId": "pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a", "found": true, "chart": { "…": "…" } }
    ],
    "groups": [
      {
        "masterPatientId": "mpt_4d3c2b1a0f9e8d7c6b5a4f3e2d1c0b9a",
        "memberIds": ["pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a", "pat_c56103bcd39c46d39f3138dd2b5e05f6"],
        "missingMemberIds": [],
        "appointments": { "status": "complete" },
        "bills": { "status": "unverified" }
      }
    ]
  }
}
```

A chart outside your organization and a chart that does not exist both answer
`{ "patientId": …, "found": false }` and nothing else.

Linked charts are returned separately, each with its own EHR, MRN, facility ids
and contacts; nothing is merged. To find a group's latest qualifying
appointment, take the latest across every member chart's items, skipping rows
with `removedFromEhrAt`.

## Consistency: snapshot, version, cursor

* **One snapshot per response.** Every chart and resource in a response is read
  in one `REPEATABLE READ` transaction at `snapshotAt`.
* **Paging is per (chart, resource).** Rows are ordered by id, which never
  changes. A large chart returns `hasMore: true` and a `nextCursor` while the
  others in the batch finish; send only the cursors you still need.
* **`version` is a content hash** of every row in that chart's resource at the
  snapshot (bills: bills plus coding groups, line items and diagnoses). Any
  insert, update or delete changes it, including writes that move no timestamp.
* **A cursor only continues the set it was cut from.** If the version changed,
  the resource returns `status: "snapshot_changed"` with no items: restart that
  (chart, resource) without a cursor. A walk either reads one unchanged set end
  to end or is told to restart; it never skips or repeats a row silently. The
  hash covers sync bookkeeping too, so a chart being re-synced while you page
  can restart more than once. Most charts fit one page and never need a cursor.

## Readiness

`items: []` means "the mirror has no rows". `readiness` says whether that can be
trusted. **Paging to the end proves nothing about the EHR**; only readiness does.

### Appointments

Readiness rests on one of two kinds of evidence (`basis`):

**`window` — facility-day sweeps.** The appointment sync reads one facility-local
week per call (ModMed per facility, EZDerm per practice) and records each day of
the week only after **every page of that week was read and written** (an appointment
the EHR returns without a patient is not mirrored, so it cannot be part of any chart's history). A week the
EHR did not answer records a failure on its days instead. The rolling sync reads
one week back through the booking horizon every five minutes; history older than
that is read by the historical backfill (see [Rollout](#rollout-and-backfill)).

| `status` | Meaning for the requested `coverage` window |
| - | - |
| `complete` | Every scope (facility or practice) of the chart's EHR instance has a verified sweep for every day, and, if the window reaches into the last week, those days were verified within the hour. An empty list means no appointment in the window. |
| `stale` | Every day is verified, but the last week's newest verification is over an hour old (the rolling sync has not been succeeding). |
| `partial` | Some days are unverified — e.g. the historical backfill has not reached them. `scopes[].firstUnverifiedDay`/`lastUnverifiedDay` say which. |
| `failed` | An unverified day's latest sweep failed (`lastFailureReason`). |
| `in_progress` | No complete evidence yet, but a per-patient refresh is queued. |
| `not_imported` | Nothing verified for the window. An empty list proves nothing. |

**`patient` — per-patient refresh.** A [refresh](#per-patient-refresh) that read
the chart's whole history in one patient-scoped EHR call makes that chart
`complete` on its own for a day (`stale` after), whatever the facility sweeps
say — so one linked group can become ready while the rest of the clinic is
still importing. `patientRefresh` shows that evidence even when `basis` is
`window`.

`asOf` is the EHR state the evidence reflects: an EHR change after it is not
covered. `excludes` lists appointment kinds the sync never imports for this EHR
(`virtual_encounters` on EZDerm).

### Bills

The bill sync keeps no completeness evidence, so the platform never calls a bill
history complete:

| `status` | Meaning |
| - | - |
| `in_progress` | `pendingHydration` of this chart's bills are discovered but not yet read in full. |
| `unverified` | Every bill the mirror knows about is read in full; whether the EHR has bills not yet discovered is unknown. |

### Groups

`groups[]` has one entry per linked identity among the requested charts. Its
status is the least ready member's, or `missing_members` while a chart linked to
the identity in your organization was not in `patientIds` — a group's history is
not whole until every member chart is read.

### What the evidence does not guarantee

* A day that left the rolling sync's one-week lookback is not re-read; an EHR
  change to an appointment more than a week after its date is not reflected
  until a backfill or per-patient refresh re-reads it.
* Only facilities linked to a live connector are swept. A ModMed facility no
  connector is linked to is outside `scopes`; a facility whose only connector is
  disconnected reads as not covered.
* EZDerm sweeps and refreshes request non-virtual encounters only (`excludes`).
* EZDerm's encounter search is unpaginated with an undocumented cap. A week of
  400+ results is re-read day by day; a day of 400+, or a patient with 400+, is
  written but never verified (`possible-truncation`).
* A sweep that crashes records nothing for the weeks it did not finish; their
  verification stops advancing and `asOf`/`liveAsOf` show it.

### Appointments the EHR no longer has

When a fully read week (or a patient refresh) no longer returns a mirrored
appointment, the row is kept with `removedFromEhrAt` set, and an
`appointment.removed` change is published. A later read that returns it again
clears the field. A rescheduled appointment can read as removed until the week
it moved to is read. Do not count removed rows as visits or bookings.

## Per-patient refresh

```json theme={null}
POST /v4/patients/history/refresh
{ "patientIds": ["pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a", "pat_c56103bcd39c46d39f3138dd2b5e05f6"] }

202
{ "code": "success", "data": { "patients": [
  { "patientId": "pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a", "status": "queued" },
  { "patientId": "pat_c56103bcd39c46d39f3138dd2b5e05f6", "status": "unsupported",
    "reason": "No verified patient-scoped appointment history call for this EHR; readiness comes from facility sweeps." }
] } }
```

* Scope `read:appointments`. Up to 25 charts. `already_queued` while a refresh
  for the chart is pending (a pending refresh older than ten minutes may be
  queued again). Foreign charts answer `not_found`.
* **EZDerm only.** The refresh uses EZDerm's patient-scoped encounter search,
  the call the platform already uses for a patient's upcoming appointments.
  ModMed has no patient-scoped appointment call verified against live traffic,
  so ModMed charts are always `unsupported` and rely on facility sweeps plus the
  historical backfill.
* **Whose login reads.** The body may carry `sessionToken`, the App Bridge
  session token of the logged-in user making the request. The refresh picks
  which EHR login reads the chart, in order: your organization's office (system)
  EZDerm connection on the patient's practice; if it has none live, the personal
  EZDerm connection of the logged-in user who made the request, when you send
  their App Bridge `sessionToken` (verified; a token that does not verify for
  your app and organization is a `401` for the whole request, and nothing is
  queued); otherwise none. Another staff member's
  personal connection, and the practice's autosubmission connection, are never
  used. With none, the chart is still `queued` here and its refresh then fails
  with `no-live-connector`. A read through a personal connection writes what
  that login can see but never marks appointments removed and never makes the
  chart `complete`: readiness reports `failed` with `lastFailureReason`
  `read-through-personal-login`. That is final for that login; retrying reads
  the same partial view again.
* A chart whose refresh is still pending answers `already_queued`, whoever
  queued it; that run reads with the login it was queued with. Once it has
  run, a request with your `sessionToken` queues a new read.
* An empty answer for a chart the mirror holds appointments for is treated as a
  failure (`empty-result-contradicts-mirror`), never as a wiped history.
* The refresh writes every returned appointment, marks the chart's mirrored
  appointments the EHR no longer returns, then records the evidence. If it
  crashes before the last step the chart stays unverified; re-running is safe.

## Identity

`chart` is one EHR chart. Charts of the same person share `masterPatientId` and
list each other in `duplicatePatientIds`; `identityLinks` gives the evidence for
each link:

| `basis` | Meaning |
| - | - |
| `name_dob` | Linked automatically: first name, last name and date of birth matched, with every other populated identifier agreeing. |
| `manual` | Linked by a person in the registry. |
| `legacy_name_dob_match` | Linked before the basis was recorded; the charts' names and dates of birth agree. |
| `legacy_unverified` | Linked before the basis was recorded with nothing on the charts corroborating it. May rest on a shared household phone or email — do not treat as the same person. |

Shared phone or email alone never links charts. MRNs are per EHR and are not
used to link charts across EHRs. Existing links are not rewritten; review
`legacy_unverified` links in the patient registry.

A link or unlink changes every member chart's `updatedAt` and publishes
`patient.identity_changed` for each on [`GET /v4/changes`](https://docs.maxcare.ai/guides/changes).

### Charts waiting for identity review

`identityReview` says whether a chart is `linked`, `pending_review` or `unmatched`.
A chart is `pending_review` when matching found identities it would not link on its
own: some identifiers agreed and others did not, for example the same name and date
of birth with a different phone. It stays unlinked, with no `masterPatientId`, until
a person decides. `candidates` names the identities in your organization it might
belong to and which identifiers agreed (`name_dob`, `phone`, `email`). A candidate is
not a link: do not merge charts on it, and treat a `phone` or `email` match without
`name_dob` as a possible relative. A chart may be `pending_review` with no candidates
listed.

A reviewer's decision arrives as an ordinary link: the chart joins the group with
basis `manual`, and every member gets a new `updatedAt` and a
`patient.identity_changed` event.

Contacts: with `include=contact` on `GET /v4/patients/{id}`, each phone number
carries `source` (`ehr_chart` or `intake`) and `sourceUpdatedAt`. Numbers are
never copied between linked charts, so you can prefer one EHR's chart.

Linked charts can disagree on phone, email and address. Being linked says the
charts are the same person; it does not say which chart's contact details are
current. The API does not tell you which contact is newer:

* `updatedAt` and an `ehr_chart` number's `sourceUpdatedAt` advance together for
  every member of a group when the group changes, and for any demographic change on
  the chart. They order chart versions, not contact edits.
* No field records when the EHR last changed a contact, or which contact the
  practice prefers.

Keep each chart's contacts separate. Do not pick a current contact by matching phone
numbers or by ordering chart ids.

### Reviewed contact sources

When linked charts disagree, a person can record which chart holds the current
phone, email or mailing address. With `include=contact` (which needs
`read:patient_pii`), `GET /v4/patients/{id}` and `GET /v4/patients` return:

```json theme={null}
{
  "identityMembershipVersion": "v1:3f2a…",
  "contactVersions": { "phone": "v1:9c1e…", "email": null, "mailingAddress": "v1:77d0…" },
  "contactSources": {
    "phone": {
      "status": "resolved",
      "decisionId": "4be6…",
      "sourcePatientId": "pat_0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a",
      "valueVersion": "v1:9c1e…",
      "membershipVersion": "v1:3f2a…",
      "decidedAt": "2026-10-07T15:04:05.000Z",
      "invalidReason": null,
      "value": "3135550123"
    },
    "email": { "status": "unresolved", "decisionId": null, "sourcePatientId": null, "valueVersion": null, "membershipVersion": null, "decidedAt": null, "invalidReason": null, "value": null },
    "mailingAddress": { "status": "stale", "invalidReason": "value_changed", "value": null, "…": "…" }
  }
}
```

`identityMembershipVersion` (also returned with `include=identity` and on every
history chart) changes whenever a chart joins or leaves the identity.
`contactVersions` is a version of this chart's own contact values. Both are opaque:
compare them as strings.

* `unresolved`: no reviewed choice is recorded. This is the normal state for most
  identities and does not mean the contact needs review: where the charts agree, or
  your own rule clearly prefers one chart, use it. A review is only for contacts the
  charts dispute. Never settle a dispute by chart id or `updatedAt`.
* `resolved`: the choice still holds. The source chart is still a member, the
  identity's members are the ones the reviewer saw, and the source chart's value
  is the one reviewed. `value` is that chart's current value.
* `stale`: the choice no longer holds (`source_unlinked`, `membership_changed`,
  `value_missing` or `value_changed`). Treat it as unresolved until a person reviews
  it again.

To pin a choice, keep `decisionId`, `valueVersion` and `membershipVersion`. Use them
only while the same response shows `status: "resolved"` and `membershipVersion`
equal to `identityMembershipVersion`. A choice is the same on every chart of the
identity and appears only to your organization. It records where the contact
comes from, not consent: your opt-out and do-not-contact rules still apply.
Nothing is chosen automatically, including for charts linked before this existed.

## Limits

* 25 charts per request, 200 rows per chart per resource, 2,500 rows per response.
* Requests count against the app's normal [rate limit](https://docs.maxcare.ai/guides/rate-limits.md).

## Rollout and backfill

* On deploy the coverage grid is empty: every chart reads `not_imported` until
  the rolling sync's first verified weeks (within five minutes per connector),
  and history older than the lookback reads `partial` until backfilled.
* Historical backfill is the `appointments-coverage-backfill` task, run per
  connector with `{ "connectorId", "from": "YYYY-MM-DD" }`. It reads unverified
  weeks newest-first, four per run, re-queues itself, resumes from the grid
  after any interruption, and reports `stalled` weeks the EHR refused three
  times. It is started by an operator, not on a schedule.
* EZDerm groups can be made ready immediately with a per-patient refresh,
  without waiting for the backfill.

## Not provided

* **Bill completeness.** No connector records bill discovery completeness.
* **ModMed per-patient history.** Needs a verified patient-scoped appointment
  endpoint (the spec shows `GET /ema/ws/v3/appointments?where=patient==…`, but
  it is unverified against live data).
* **Notes** are not part of this endpoint.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.