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

# Availability & the appointment finder

> Choosing between bookable slots and the EHR's own appointment finder, every finder option, acting as the logged-in user, holds, and errors

Three endpoints answer "when can this patient be seen?". They read the same EHR
but answer different questions:

| Endpoint | What it returns | Use it for |
| - | - | - |
| `GET /v4/schedule/slots` | **Bookable** slots for a window. Max AI holds and pending bookings are already subtracted, and override-only slots are never offered. | Everything you book from: slot pickers, the final check before `POST /appointments`. |
| `GET /v4/schedule/finder` | **The EHR's own view**: one search of its appointment finder, every option it offers, grouped by provider and facility. Nothing is subtracted. | Showing what staff see on the EHR's finder screen, first-available searches, weekday and part-of-day filters, override-only slots. |
| `GET /v4/schedule/finder/provider-locations` | Provider and facility pairs that **can** take an appointment type (eligible, not necessarily open). | Building provider and location pickers before searching. |

All three need the `read:schedule` scope, and all three accept the optional
[`X-Session-Token`](#acting-as-the-logged-in-user) header.

> [!WARNING]
>
> **Book from `/schedule/slots`, not from `/schedule/finder`.** A finder slot can
> already be held by another booking session in Max AI, or taken by a booking
> accepted but not yet written to the EHR. `/schedule/slots` subtracts both. A
> booking is re-checked against the EHR either way, so the cost of booking from the
> finder is a `409 slot_unavailable`, not a double-book.

## Which EHRs support what

Ask `GET /v4/schedule/capabilities` once, at configuration time:

```json theme={null}
{
  "success": true,
  "data": {
    "ehr": "modmed",
    "slotSearchStrategy": "native",
    "appointmentFinder": true,
    "availabilitySearchOptions": [
      "timeFrame=first_available",
      "timeFrame=specific_date",
      "timeFrame=date_range",
      "timeFrame=time_frame",
      "weekday",
      "timeOfDay",
      "display",
      "allowOverride",
      "alternateDurations",
      "afterFirstAppointment",
      "bufferMinutes"
    ],
    "availabilityCompleteness": "complete"
  }
}
```

(Other capability fields omitted.)

* `appointmentFinder: false` means both finder endpoints return
  **501 `capability_unsupported`** for this organization. Today ModMed has a finder
  and EZDerm does not. `/schedule/slots` works on both.
* `availabilitySearchOptions` lists the finder parameters this EHR honours, by
  public name. A `timeFrame` mode or option missing from it is a **400** that names
  the parameter.
* `availabilityCompleteness: "complete"` means this EHR's availability is read live,
  so an untruncated `/schedule/slots` answer is the whole truth. See
  [completeness](#completeness).

## Bookable slots: `GET /v4/schedule/slots`

```
GET /v4/schedule/slots?appointmentTypeId=atp_9f1c2d3e4b5a46f78901234567890abc&facilityId=fac_2706aaaa4bbbccccddddeeeeffff1111&from=2026-11-02&to=2026-11-15
X-Session-Token: <maxcare.appToken()>
```

```json theme={null}
{
  "success": true,
  "data": {
    "slots": [
      {
        "providerId": "prv_35103413aaaa4bbbccccddddeeeeffff",
        "facilityId": "fac_2706aaaa4bbbccccddddeeeeffff1111",
        "startsAt": "2026-11-02T09:15:00-05:00",
        "endsAt": "2026-11-02T09:30:00-05:00",
        "remainingCapacity": 1,
        "templateIds": []
      }
    ],
    "nextAvailableByProvider": {
      "prv_35103413aaaa4bbbccccddddeeeeffff": "2026-11-02"
    },
    "meta": {
      "sourceKind": "live_read",
      "strategy": "native",
      "completeness": "complete",
      "generatedAt": "2026-11-01T14:00:00.000Z",
      "actingAs": "user",
      "resolvedBy": "personal_connector"
    }
  }
}
```

| Parameter | Type | Required | Notes |
| - | - | - | - |
| `appointmentTypeId` | `atp_…` | yes | |
| `from`, `to` | `YYYY-MM-DD` | yes | Both inclusive, clinic calendar days. At most 120 days apart. |
| `facilityId` | `fac_…` | no | Repeat or comma-separate for several. Defaults to every facility. Facility-limited keys must pass it. |
| `providerIds` | `prv_…` | no | Comma-separated. |
| `durationMinutes` | int 5–480 | no | Defaults to the type's default duration. |
| `newPatient` | bool | no | Ignored on ModMed. |
| `consistency` | `strong` | no | No effect on ModMed: every search is already live. On EZDerm it asks the EHR instead of Max AI's ledger. |

### How ModMed slots are produced

On EHRs read live (ModMed, through EMA's appointment finder), **every** slot search
asks the EHR; there is no cached grid. The response says so:

* `meta.sourceKind: "live_read"` and `meta.strategy: "native"`.
* `remainingCapacity` is the EHR's open seats minus Max AI holds and minus bookings
  Max AI has accepted but not yet written to the EHR.
* `templateIds` is empty: finder slots are not tied to a template id you can pin.

**The 5-open-days cap is handled for you.** EMA's finder returns at most 5 open
days per provider per call. For a longer window Max AI calls again from the day
after the earliest cut-off, up to 8 calls in total (about 40 open days per
provider). Each call takes roughly 0.2–0.8 s.

On EZDerm the default grid still comes from Max AI's capacity ledger
(`sourceKind: "mirror"`, `strategy: "synthesized"`); `consistency=strong` asks the
EHR.

### Completeness

Branch on `meta.completeness`, never on `strategy`:

| Value | Meaning |
| - | - |
| `complete` | The EHR answered live for the whole window. A slot that is absent really is unavailable. |
| `lower_bound` | Real availability may be larger than what is listed. Either the EHR still capped its answer after 8 calls (a long window with a busy provider), or the grid came from Max AI's ledger, which can lag. |

`lower_bound` cannot cause a double-book; it only under-offers. For an empty
`lower_bound` grid, say "nothing bookable online" rather than "the practice is
full", or narrow the window and search again.

### `nextAvailableByProvider`

The earliest bookable day per provider **inside `[from, to]`**, computed after holds
are subtracted, so every listed day has at least one slot in `slots`. A provider with
nothing bookable in the window is absent. It never reports a day beyond `to`; to
look further out, widen the window.

## The EHR's view: `GET /v4/schedule/finder`

One call to the EHR's appointment finder, with every option it offers. Ids are
mapped to Max AI ids; nothing else is changed.

### Parameters

| Parameter | Type | Default | Used by | Notes |
| - | - | - | - | - |
| `appointmentTypeId` | `atp_…` | required | every mode | |
| `timeFrame` | `first_available` · `specific_date` · `time_frame` · `date_range` | required | | The search mode, below. |
| `date` | `YYYY-MM-DD` | — | `specific_date` (required) | One clinic calendar day. |
| `from`, `to` | `YYYY-MM-DD` | — | `date_range` (both required) | Both inclusive, at most 120 days apart. |
| `offset` | int ≥ 1 | — | `time_frame` (required) | How far out to start searching. |
| `offsetUnit` | `days` · `weeks` · `months` | — | `time_frame` (required) | Unit of `offset`. |
| `facilityId` | `fac_…` | every facility on the EHR instance | any | Repeat or comma-separate. Facility-limited keys must pass it. |
| `providerIds` | `prv_…` | every provider | any | Comma-separated. Applied to the EHR's answer: the finder always searches every provider. |
| `weekday` | `monday` … `sunday` | every day | any | Repeat or comma-separate (`weekday=monday&weekday=wednesday`). In the clinic's local time. |
| `timeOfDay` | `anytime` · `morning` · `afternoon` · `evening` | `anytime` | any | Part of the day, as the EHR defines it. |
| `display` | `by_provider` · `by_time` | `by_provider` | any | The EHR's result order. The slots are identical either way. |
| `durationMinutes` | int 5–480 | the type's default | any | Visit length. |
| `alternateDurations` | bool | `true` | any | Let the type's alternate durations match, so one start can appear twice with two durations. |
| `allowOverride` | bool | `false` | any | Also return slots that need a staff override; they carry `overrideAllowed: true`. |
| `afterFirstAppointment` | bool | `false` | any | **Pass-through, effect unverified.** Sent to the EHR as is. |
| `bufferMinutes` | int 0–240 | `0` | any | **Pass-through, effect unverified.** Presumably minutes kept free around booked appointments. |

A mode's own parameters are required, and another mode's parameters are refused:
`timeFrame=specific_date&from=2026-11-02` is a 400 naming `from`.

### Modes

> [!NOTE]
>
> On ModMed **every mode returns at most 5 open days per provider**. A capped group
> comes back with `truncated: true`. Unlike `/schedule/slots`, the finder makes no
> follow-up calls: move or narrow the window to see more.

**`first_available`**: each provider's earliest openings, starting at that
provider's own first open day (which can be weeks out).

```
GET /v4/schedule/finder?appointmentTypeId=atp_9f1c2d3e4b5a46f78901234567890abc&timeFrame=first_available
```

**`specific_date`**: one clinic day. Never truncated.

```
GET /v4/schedule/finder?appointmentTypeId=atp_9f1c2d3e4b5a46f78901234567890abc&timeFrame=specific_date&date=2026-11-02&timeOfDay=morning
```

**`time_frame`**: like `first_available`, but starting `offset` `offsetUnit` from
today.

```
GET /v4/schedule/finder?appointmentTypeId=atp_9f1c2d3e4b5a46f78901234567890abc&timeFrame=time_frame&offset=2&offsetUnit=weeks
```

**`date_range`**: every provider at the listed facilities between `from` and `to`.

```
GET /v4/schedule/finder?appointmentTypeId=atp_9f1c2d3e4b5a46f78901234567890abc&timeFrame=date_range&from=2026-11-02&to=2026-11-15&facilityId=fac_2706aaaa4bbbccccddddeeeeffff1111,fac_8811aaaa4bbbccccddddeeeeffff2222&weekday=monday&weekday=wednesday&timeOfDay=afternoon&durationMinutes=30&display=by_time
```

### Response

```json theme={null}
{
  "success": true,
  "data": {
    "groups": [
      {
        "providerId": "prv_35103413aaaa4bbbccccddddeeeeffff",
        "facilityId": "fac_2706aaaa4bbbccccddddeeeeffff1111",
        "timezone": "America/Detroit",
        "truncated": false,
        "slots": [
          {
            "startsAt": "2026-11-02T13:15:00-05:00",
            "endsAt": "2026-11-02T13:30:00-05:00",
            "durationMinutes": 15,
            "remainingCapacity": 1,
            "totalCapacity": 2,
            "overrideAllowed": false
          }
        ]
      }
    ],
    "meta": {
      "sourceKind": "live_read",
      "strategy": "native",
      "generatedAt": "2026-11-01T14:00:00.000Z",
      "unmappedGroupCount": 0,
      "actingAs": "system",
      "resolvedBy": "facility_system_connector"
    }
  }
}
```

| Field | Meaning |
| - | - |
| `groups[].timezone` | IANA zone of the facility. `startsAt` / `endsAt` carry that zone's offset. |
| `groups[].truncated` | The EHR capped this group; more availability exists after its last slot. |
| `slots[].remainingCapacity` | Open seats **as the EHR counts them**. Max AI holds and pending bookings are not subtracted. |
| `slots[].totalCapacity` | Seats the practice defines at that time (overlapping calendar preferences add up). |
| `slots[].overrideAllowed` | Bookable only with a staff override. Only ever `true` when you passed `allowOverride=true`. |
| `meta.unmappedGroupCount` | Groups for a provider or facility Max AI has not mirrored yet. They are dropped, never returned with an EHR id. A non-zero count usually clears after the next directory sync. |

The finder never returns EHR-internal ids or staff names.

## Provider-location pairs: `GET /v4/schedule/finder/provider-locations`

```
GET /v4/schedule/finder/provider-locations?appointmentTypeId=atp_9f1c2d3e4b5a46f78901234567890abc
```

```json theme={null}
{
  "success": true,
  "data": {
    "pairs": [
      {
        "providerId": "prv_35103413aaaa4bbbccccddddeeeeffff",
        "facilityId": "fac_2706aaaa4bbbccccddddeeeeffff1111"
      }
    ],
    "ehrSearchThreshold": 300,
    "meta": {
      "unmappedPairCount": 0,
      "actingAs": "system",
      "resolvedBy": "org_system_connector"
    }
  }
}
```

* `facilityId` (optional, repeatable or comma-separated) narrows the pairs.
* Pairs are limited to the facilities your API key may see. Unlike the other two
  endpoints, a facility-limited key may omit `facilityId` here.
* A pair is **eligible**, not necessarily open: a provider can take the type at a
  facility and still have no open slots.
* `ehrSearchThreshold` is the EHR's own value, passed through. **Its meaning is
  unknown**; do not build on it.

## Acting as the logged-in user

Send the App Bridge token of the user who is using your app:

```ts theme={null}
const token = await maxcare.appToken();
await fetch(`${API}/v4/schedule/slots?${query}`, {
  headers: { Authorization: `Bearer ${apiKey}`, "X-Session-Token": token },
});
```

Max AI verifies the token (signed for your app and this organization, valid for
120 seconds) and then picks the EHR login:

1. The user's **own** EHR login (their personal connector), when it is live, has a
   session and is on the same EHR instance.
2. Otherwise the organization's **system** connector, exactly as without a token.

Every response says which:

| `meta.actingAs` | `meta.resolvedBy` | Login used |
| - | - | - |
| `user` | `personal_connector` | The user's own EHR login |
| `system` | `facility_system_connector` | The system connector assigned to the facility |
| `system` | `org_system_connector` | The organization-wide system connector |

* **No header**: always the system connector. The API key creator's login is never
  used.
* **A token that does not verify** (bad signature, another app, another
  organization, expired): **401 `authorizer_unverified`**. Mint a fresh token and
  retry; tokens are short-lived by design.
* There is no user-id header. Only a verified token can name a user, so one app
  key cannot act as any user it chooses.

### Holds and writes take the same header

`POST /v4/schedule/holds`, `POST /v4/appointments`, reschedule and cancel accept
`X-Session-Token` with the same verification and the same rule. Their response
`meta` carries `actingAs` and `resolvedBy`.

The appointment resource carries **`writtenBy.resolvedBy`**: which EHR login wrote
its last change. It is visible on `GET /v4/appointments/{id}` too.

* **While the write is queued**, it is the login the write is intended for.
* **Once it lands**, it is the login that actually wrote it.
* If the user's personal login stops working before the queued write runs (session
  expired, disconnected, no scheduling rights), Max AI writes through the system
  connector instead and `writtenBy.resolvedBy` changes to the system value.
* It is `null` for appointments Max AI did not write.

Writes made through the user's own login are attributed to that user in the EHR's
own history.

## Holds

`POST /v4/schedule/holds` reserves a slot while the patient finishes booking. Pass
the hold's `id` as `holdId` when you create the appointment.

* Holds are **advisory** (`advisory: true`): they stop two Max AI booking sessions
  from selling the same seat, but staff booking directly in the EHR can still take
  it.
* On EHRs read live (ModMed) the hold first reads the slot's open seats from the
  EHR, then subtracts bookings Max AI has accepted but not yet written and other
  active holds.
* **409 `slot_unavailable`**: the EHR no longer shows the slot, or Max AI holds and
  pending bookings already use all its seats. Search again.
* **502 `ehr_unavailable`**: the EHR could not be read in time. Nothing was held;
  safe to retry. Holds fail closed: without the EHR's answer nothing guards the
  seat.

Booking on ModMed is checked the same way, with or without a `holdId`.

## Errors and limits

| Status | `code` | When |
| - | - | - |
| 400 | `bad_request` | Invalid parameter; a mode missing its parameters or carrying another mode's; a window over 120 days; an id from another organization or EHR instance; a finder option the EHR does not honour (the message names the public parameter). |
| 401 | `authorizer_unverified` | `X-Session-Token` present but did not verify. |
| 401 | `unauthorized` | Missing or invalid API key. |
| 403 | `forbidden` | Missing `read:schedule` (or `write:appointments` for holds), or a facility-limited key that did not pass `facilityId` on `/slots` or `/finder`. |
| 404 | `not_found` | A `facilityId` outside a facility-limited key's facilities. Answered as not-found so keys cannot probe ids. |
| 409 | `slot_unavailable` | Holds and bookings: the seat is gone. |
| 429 | `rate_limit_exceeded` | Your key's [rate limit](https://docs.maxcare.ai/guides/rate-limits.md). |
| 501 | `capability_unsupported` | The finder endpoints on an EHR without a finder (`appointmentFinder: false`). |
| 502 | `ehr_unavailable` | The EHR did not answer within **8 seconds**, or answered with an error. Safe to retry. |
| 503 | `no_booking_identity` | The organization has no live system connector for this EHR instance. One is required even when the user has a personal login. |
| 503 | `ehr_unavailable` | The appointment type's default duration could not be read. Pass `durationMinutes` explicitly, or retry. |

* Live searches run at interactive priority, ahead of background syncs.
* A live search **stops when your client disconnects**, so abandoning a request
  does not keep loading the EHR.
* There is no caching: each request asks the EHR.

## Known unknowns

These are vendor behaviours not yet proven. Do not build on them.

* `afterFirstAppointment` and `bufferMinutes` are accepted by ModMed but showed no
  effect on the test days available. Their meaning is unverified.
* `ehrSearchThreshold` (300 on every tenant seen) has no known meaning.
* Every slot observed with `allowOverride=true` still had `overrideAllowed: false`.
* A finder slot does not identify the calendar template behind it, so
  `templateIds` is empty on ModMed slots.


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