# Scheduled consultations

A video consultation at a time the patient chose, with a named physician —
alongside (or instead of) the on-demand queue.

The two modalities differ only in how the physician is decided. On demand, a
matcher pairs the patient with the first eligible clinician who presses Ready.
Scheduled, the clinician is chosen **with the time**, days in advance, and the
call starts when both sides are in the appointment's waiting room.

Everything below assumes the [getting-started](getting-started.md) setup:

```bash
export NATZAR_API="https://<your-assigned-api-host>"
export NATZAR_KEY="pp_test_..."
```

Your tenant must have `telehealthEnabled` **and** scheduled booking switched on
(`Consultation → Settings` in the portal). Without it every route below answers
`409 feature_disabled`.

## 1. Publish availability

Patients can only book time a physician has published. A schedule is stored
as **intent**, never as slots: recurring **rules** ("Tuesdays 09:00–12:00",
every week or every N weeks, optionally between two dates) plus dated
**exceptions** — time off (`block`) or extra hours (`open`) on one date or
across a range of dates. Blocks always beat opens.

### Replace the whole rota

For a rostering system that owns the schedule, `PUT` replaces **everything**
the physician has — every rule and every exception, whatever its date — with
what you send:

```bash
curl -X PUT "$NATZAR_API/v1/physicians/$PHYSICIAN_ID/schedule" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{
    "rules": [
      {"weekday": 1, "startMinute": 540, "endMinute": 720},
      {"weekday": 3, "startMinute": 540, "endMinute": 720},
      {"weekday": 3, "startMinute": 840, "endMinute": 1020, "specialty": "gastroenterology"},
      {"weekday": 6, "startMinute": 540, "endMinute": 780, "intervalWeeks": 2, "effectiveFrom": "2026-09-19"}
    ],
    "exceptions": [
      {"date": "2026-07-14", "kind": "block", "reason": "Conference"},
      {"date": "2026-12-21", "endDate": "2027-01-04", "kind": "block", "reason": "Leave"}
    ]
  }'
```

`weekday` is 0 = Sunday … 6 = Saturday. `startMinute`/`endMinute` are minutes
from **local midnight** in the physician's calendar zone — not instants, which
is what makes a rule survive daylight saving instead of sliding by an hour
twice a year. That zone is ONE per physician: their own (`timezone` on this
body or on `PATCH`, `null` to go back to the clinic's) when set, else the
clinic's. Do not put a `timezone` on a rule: one that names a different zone
from the physician's is refused (`rule_zone_mismatch`) — set the physician's
zone instead. A zone change keeps every rule's wall-clock meaning.
A rule with a `specialty` serves only that specialty (a dedicated clinic); one
without serves whatever the physician covers. `intervalWeeks: 2` is a
fortnightly clinic: the first occurrence is the first `weekday` on or after
`effectiveFrom` (required for N > 1), then every 14 days. An exception with
`endDate` covers every day of the range — a three-week leave is one row.

### Edit like a calendar

A calendar does not re-send the rota to block one afternoon. `PATCH` applies a
**change set** — rows with an `id` (from a prior read) are updated, rows
without are created, and the two id lists are deleted; everything else stays
exactly as it was, however far ahead it lies:

```bash
curl -X PATCH "$NATZAR_API/v1/physicians/$PHYSICIAN_ID/schedule?from=2026-09-01&to=2026-09-30" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{
    "exceptions": [
      {"date": "2026-09-14", "kind": "block", "startMinute": 840, "endMinute": 960, "reason": "Dentist"},
      {"id": "e_1", "date": "2026-09-19", "kind": "open", "startMinute": 540, "endMinute": 720}
    ],
    "deletedRuleIds": ["r_9"]
  }'
```

One malformed row refuses the whole call (`400 invalid_request` naming the
row and the problem); an `id` that is not this physician's is refused on
update and ignored on delete.

The **schedule engine** that turns a calendar gesture into the right change
set ships in `@natzar/client/contract` and is the same code the platform
runs: `openWindow` / `closeWindow` ("make me available / unavailable 14:00–16:00
on the 14th" — cutting back time off or extra hours first, never touching a
rule), `blockDates` (leave), `unblockDate`, `endRuleBefore` (stop a pattern
from a date, keeping the past it was booked against), `resolveDays` (rules +
exceptions → bookable intervals per date, for a live preview) and
`diffSchedule` (loaded document → edited document → PATCH body). See the
`@natzar/client` README.

### Read it

```bash
curl "$NATZAR_API/v1/physicians/$PHYSICIAN_ID/schedule?from=2026-09-01&to=2026-10-31" \
  -H "Authorization: Bearer $NATZAR_KEY"
```

```json
{
  "physicianId": "…",
  "rules": [{"id": "r_1", "weekday": 1, "startMinute": 540, "endMinute": 720, "intervalWeeks": 1, "…": "…"}],
  "exceptions": [{"id": "e_1", "date": "2026-09-14", "kind": "block", "startMinute": 840, "endMinute": 960, "…": "…"}],
  "days": [{"date": "2026-09-01", "intervals": []}, {"date": "2026-09-07", "intervals": [{"start": 540, "end": 720, "specialty": null}]}, "…"],
  "from": "2026-09-01",
  "to": "2026-10-31",
  "today": "2026-09-15",
  "timezone": "America/Toronto",
  "clinicTimezone": "Europe/Zurich",
  "schedulingTimezone": "America/Toronto",
  "upcomingAppointments": 3,
  "slotMinutes": 20,
  "scheduleHorizonDays": 28,
  "requiredThrough": "2026-10-13",
  "coveredThrough": "2026-10-13"
}
```

`timezone` is the physician's effective calendar zone (`schedulingTimezone`
when they set one, else `clinicTimezone`); `today`, `from`/`to`, every rule
minute and every exception date are read in it. `upcomingAppointments` is how
many booked appointments lie ahead — the number to show before a zone change,
since those keep their instants and re-render on the new clock.

`rules` are always the whole rota. `exceptions` and `days` describe `from`..`to`
(inclusive local dates; default today → `requiredThrough`; at most 400 days per
read — page by range to look further). `days` is the engine's resolution for
every date of the range, so you can draw the calendar without running the
engine yourself.

`coveredThrough` is the last date covered **without a gap** — a rota with
Mondays booked for a year but nothing else is not a covered schedule, and
reporting the far Monday would tell a physician they were done when they had
published one day a week. Compare it with `requiredThrough` to surface a
shortfall.

## 1b. Record state licences (if your tenant enforces them)

A tenant can require that a physician only be routed patients in a state they
hold a current licence for. It is **off by default**; ask us to enable it, and
record licences *before* we do — with it on, a physician with none is offered
nothing.

```bash
curl -X PUT "$NATZAR_API/v1/physicians/$PHYSICIAN_ID/licenses" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{
    "licenses": [
      {"state": "CA", "licenseNumber": "A-123456", "expiresAt": "2027-03-31"},
      {"state": "NY", "licenseNumber": "NY-98765", "expiresAt": "2026-09-30"}
    ]
  }'
```

`state` is an ISO 3166-2 subdivision code **without** the country prefix (`CA`,
not `US-CA`); `country` defaults to your tenant's own. The pair is what matters —
`CA` is California to a US tenant and Canada to a Canadian one.

The response separates out what is about to lapse:

```json
{
  "physicianId": "…",
  "licenses": [ … ],
  "expiringSoon": [{"state": "NY", "expiresAt": "2026-09-30", "…": "…"}],
  "licenseEnforcement": true
}
```

**Surface `expiringSoon`.** An expired licence stops routing automatically on
its expiry date — which is the point — but it does so with no error anywhere,
and the first symptom is a patient being told nobody can see them.

A `state` we cannot resolve to a real jurisdiction is **refused** (`400
invalid_request`), not quietly dropped: a licence list that silently lost an
entry looks like coverage that is not there.

### Tell us where the patient is

Under enforcement, a consult for a patient whose state we do not know **cannot
be routed at all** — failing open on an empty address field is the failure the
setting exists to prevent. So send it on the patient upsert:

```bash
curl -X POST "$NATZAR_API/v1/patients" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{"externalId": "your-patient-42", "…": "…", "state": "CA", "country": "US"}'
```

If you do not, nothing breaks: `GET /slots` answers with
`licenseBlock: {"reason": "unknown_jurisdiction"}` and our own booking page asks
the patient directly — which is the more correct question anyway, since the
jurisdiction that governs a telemedicine encounter is where the patient
physically is at the time, not their billing address.

When we know the state and nobody is licensed there, `/slots` says so plainly
rather than returning an empty calendar:

```json
{"slots": [], "noEligiblePhysicians": true,
 "licenseBlock": {"reason": "no_licensed_physician", "state": "CA", "label": "California"}}
```

That is a permanent answer, not "try again later" — tell the patient so.

## 2. Create a scheduled consult

Same endpoint as the on-demand one, plus `mode`:

```bash
curl -X POST "$NATZAR_API/v1/telehealth-consults" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{
    "externalPatientId": "your-patient-42",
    "mode": "scheduled",
    "specialty": "gastroenterology",
    "context": "3 weeks of epigastric pain, worse after meals. On omeprazole 20mg."
  }'
```

`specialty` is a routing hint, not a constraint you must get right: an unknown
or inactive slug degrades to the tenant's catch-all specialty rather than
failing the request. A bad guess costs the patient a generalist, never a
consultation. Omit it to leave the consult unrouted.

The consult comes back `status: "invited"` with no time yet.

## 3. Offer the times

```bash
curl "$NATZAR_API/v1/telehealth-consults/$CONSULT_ID/slots?timezone=America/Los_Angeles" \
  -H "Authorization: Bearer $NATZAR_KEY"
```

```json
{
  "slots": [
    {"startsAt": "2026-06-16T07:00:00.000Z", "endsAt": "2026-06-16T07:20:00.000Z",
     "localDate": "2026-06-16", "practitionerIds": ["doc_1", "doc_7"]}
  ],
  "timezone": "America/Los_Angeles",
  "clinicTimezone": "Europe/Zurich",
  "truncated": false,
  "nextFrom": null,
  "slotMinutes": 20,
  "bookingHorizonDays": 14,
  "cancellationWindowMinutes": 120,
  "waitingRoomOpensMinutes": 10,
  "appointment": null,
  "noEligiblePhysicians": false
}
```

Four things to render correctly:

- **Pass the patient's zone, group by `localDate`, label with `timezone`.**
  `timezone` is the zone the grid is expressed in — the one you sent (the
  patient's device zone, `Intl.DateTimeFormat().resolvedOptions().timeZone`),
  else the clinic's. Every `localDate` is a calendar day in it, so a 23:30
  clinic slot files under the patient's own evening, not tomorrow. `from`/`to`
  are read in it too. An unknown zone is `400 invalid_request`. When
  `clinicTimezone` differs, say so ("the clinic is 9 h ahead") — the
  appointment is a commitment between two people, and the one thing that must
  never happen is the two of them reading different hours off the same screen
  without knowing it.
- **`truncated: true` means the list was cut at the server's cap.** Page with
  `?from=<nextFrom>` (a local date in `timezone`) rather than treating the end
  of the list as the end of availability.
- **Do not show `practitionerIds` as a choice.** Identical starts from several
  physicians collapse into one offer; `book` picks the least-loaded of them.
  Showing a wall of names the patient has no basis to choose between makes a
  worse experience *and* a worse rota.
- **`noEligiblePhysicians: true` is not an empty calendar.** It means nobody in
  the tenant covers this specialty at all, and no amount of waiting will help.
  Say so, rather than showing an empty grid.

## 4. Book

```bash
curl -X POST "$NATZAR_API/v1/telehealth-consults/$CONSULT_ID/book" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{"startsAt": "2026-06-16T07:00:00.000Z", "patientTimezone": "America/Los_Angeles"}'
```

```json
{
  "consult": {"…": "…", "status": "scheduled", "scheduledAt": "2026-06-16T07:00:00.000Z", "patientTimezone": "America/Los_Angeles"},
  "appointment": {
    "startsAt": "2026-06-16T07:00:00.000Z",
    "practitionerName": "Dr. Sarah Chen, MD",
    "waitingRoomOpensAt": "2026-06-16T06:50:00.000Z",
    "cancellableUntil": "2026-06-16T05:00:00.000Z",
    "timezone": "America/Los_Angeles",
    "clinicTimezone": "Europe/Zurich",
    "physicianTimezone": "Europe/Zurich",
    "patientTimezone": "America/Los_Angeles"
  },
  "rescheduled": false
}
```

`patientTimezone` is the zone the patient booked from. It is stamped on the
consult, and every confirmation and reminder we send renders the time in it —
with the physician's clock in brackets when the two read a different hour.
The appointment's `timezone` is the zone this response is expressed in (the
one you sent); `physicianTimezone` is the physician's calendar zone, for a
"their clock" line.

The reservation is atomic, and so is the **overlap** check: the physician's
reserved intervals for the day are held in a version-conditioned ledger item
written in the same transaction as the reservation and the consult update, so
two bookings that overlap — identical starts or not — cannot both succeed.
The same patient may hold at most `maxOpenBookingsPerPatient` upcoming
appointments (`409 too_many_open_bookings`). The loser of a race gets:

```json
{"error": {"code": "slot_taken", "message": "That time is no longer available",
           "details": {"slots": [ … ]}}}
```

Retry with a **different** time from the attached fresh list, never the same one.

Calling `book` again on a consult that already has a time **reschedules** it:
same consult, same id, same embed session, a different slot. There is no
cancel-then-rebook dance, precisely because that would drop the patient's
appointment on the floor between the two calls.

## 5. The appointment

Cancel with the ordinary cancel route — it releases the physician's slot in the
same write that changes the status:

```bash
curl -X POST "$NATZAR_API/v1/telehealth-consults/$CONSULT_ID/cancel" \
  -H "Authorization: Bearer $NATZAR_KEY"
```

To let the patient attend, mint an embed session
(`POST /v1/telehealth-consults/{id}/embed-session`) and render
`<natzar-telehealth>` exactly as for an on-demand consult. The widget shows the
slot grid before a time is chosen and the waiting room from
`waitingRoomOpensAt` — you do not switch surfaces.

A booked call **never rings and is never matched**. Both sides report presence
in the waiting room, and the moment each sees the other, the call starts. That
is why there is nothing for you to poll here beyond what the widget already
does.

## 6. What the patient hears from us

If the tenant has messaging enabled, the platform sends the patient a booking
confirmation, reminders (by default 24 hours and 30 minutes before), a
cancellation notice and a no-show notice, on their own channel and in their own
language, **on their own clock** — the `patientTimezone` sent with the booking,
else the clinic's — with the physician's clock in brackets when it differs.
You do not need to build any of it — and you should not duplicate it.
