---
title: Physicians
group: Guides
---

# Physicians — the clinician side of the API

Everything a clinician does on the platform — going on the live video rota,
taking a call, answering written consultations, keeping a booked agenda,
publishing availability, recording licences — can be done through the API,
so your physicians work **inside your own portal** and never open ours.

This guide is the server-and-browser recipe. The
[`@natzar/client/physician`](https://www.npmjs.com/package/@natzar/client)
entry point wraps all of it (polling, heartbeats, expiry); the raw routes are
here so you can see what it does and go without it.

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

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

## 1. Who is acting — the three physician credentials

An API key authenticates your **application**, never a person. Anything that
signs a clinical act with a physician's name — a reply on a patient's record,
"I am available", a licence — needs a second credential that names the
clinician. There are three, and every physician route accepts any of them:

| Credential | How | Meant for |
| --- | --- | --- |
| **Session token** | `Authorization: Bearer <sessionToken>` (instead of the key) | The clinician's **browser**. Minted by your server (§2), short-lived, restricted to the physician routes. |
| **Cognito ID token** | Key + `X-Natzar-Physician: <id token>` | A clinician who also has a login on our portal. |
| **Asserted id** | Key + `X-Natzar-Physician-Id: <physicianId>` | Your **server**, acting for a clinician you have already authenticated. Available unless your account has physician assertion switched off (`403 forbidden`). |

Precedence when several are present: session, then ID token, then asserted
id. A session that also asserts a *different* physician is `400
invalid_request`.

Wherever a route takes `/v1/physicians/{id}/…`, `{id}` may be the literal
**`me`** — the acting physician. With no physician credential, `me` is `401
unauthorized`.

**Self-only routes.** Presence, heartbeat and availability are statements
about a human's own presence, so with a physician credential the addressed
physician must be the acting one (`400 invalid_request` otherwise). The
session-scoped schedule / licence / specialty / language writes are self-only
too. The key alone keeps its administrative reach over every physician of the
tenant for schedule, licences, specialties and languages.

## 2. Single sign-on: mint a session on your server

Your clinician is already signed in to *your* portal. Exchange that for a
Natzar physician session — **tenant key only**; a session may not mint
another (`403 forbidden`):

```bash
curl -X POST "$NATZAR_API/v1/physicians/$PHYSICIAN_ID/session" \
  -H "Authorization: Bearer $NATZAR_KEY" -H 'Content-Type: application/json' \
  -d '{"ttlSeconds": 3600}'
```

```json
{
  "sessionToken": "eyJ…",
  "expiresAt": "2026-09-15T05:00:00.000Z",
  "apiUrl": "https://<your-assigned-api-host>/v1",
  "physician": {"id": "…", "externalId": "your-user-id", "givenName": "Léa", "familyName": "Morin", "presence": {"status": "offline"}}
}
```

Send the whole object to the browser as-is: it is exactly what
`connectPhysician(session)` takes. The token is a browser-grade credential:

- it reaches **only** the physician-side routes (the list in §7); anything
  else answers `403 forbidden` — keep the key on your server for those;
- it always acts as its **own** physician: another physician's resources
  are `403 forbidden` (or `404` where the record is not even visible);
- it dies on `expiresAt` (`401 unauthorized`), and a key rotation kills it
  early. The SDK reports the first `401` through `onExpired`; mint a fresh
  session and hand it to `desk.refresh(session)`.

**Auto-provisioning.** If the clinician has no physician record yet, create
one first with `POST /v1/physicians` keyed by *your* user id
(`externalId`) — an identical repeat is idempotent (200), so "look up by
`externalId`, create if missing, mint" is safe to run on every sign-in.
`409 email_in_use` means that email already backs a physician you did not
create: a human has to reconcile it.

## 3. Presence — the live video rota

```bash
# on the rota; the matcher sees me at once
curl -X POST "$NATZAR_API/v1/physicians/me/presence" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"ready": true}'
```

```json
{"presence": {"status": "ready", "readyAt": "…", "lastSeenAt": "…", "stale": false}, "activeConsult": null}
```

A `ready` physician stays on the rota only while **heartbeats** arrive:

```bash
curl -X POST "$NATZAR_API/v1/physicians/me/heartbeat" -H "Authorization: Bearer $SESSION"
```

Every **15 s**, with a **45 s** TTL (`PHYSICIAN_HEARTBEAT_INTERVAL_SECONDS`,
`PHYSICIAN_PRESENCE_TTL_SECONDS` in the contract). A closed laptop drops off
the rota on its own — that is the point: no phantom clinicians. `presence.stale`
is the platform telling you the beats stopped arriving. `{"ready": false}`
takes the physician off the rota immediately; a physician on a call reads
`busy` until it ends.

## 4. The workspace — one read, polled

```bash
curl "$NATZAR_API/v1/physicians/me/workspace" -H "Authorization: Bearer $SESSION"
```

```json
{
  "physician": {"id": "…", "presence": {"status": "ready"}, "asyncAvailable": true},
  "telehealth": {
    "visible": true,
    "waiting": 2,
    "readyPhysicians": 1,
    "queue": [{"id": "…", "patient": {"givenName": "Alex", "familyName": "T.", "birthdate": "…", "sex": "female"}, "enqueuedAt": "…", "context": "…"}],
    "active": null,
    "upcoming": [{"id": "…", "scheduledAt": "…", "patient": {…}}]
  },
  "async": {"available": true, "queued": [...], "mine": [...], "overdue": [...]}
}
```

It is the whole clinician screen: presence, the live queue (`visible` says
whether the matcher can see this physician), the call for THIS physician
(`active`, when the matcher has reserved them — see §5), the next seven
days of booked appointments, and the written inbox split into the three
piles a clinician thinks in. Poll it (the SDK does, and only re-renders on
change); every list is capped at 50 (`PHYSICIAN_WORKSPACE_LIST_CAP`).

## 5. On-demand video: ring → room → end

The platform pairs a waiting patient with the first eligible `ready`
physician. You do **not** pick patients off the queue — the physician just
stays ready, and the workspace's `telehealth.active` flips to a consult in
`ringing` (the patient's client is confirming it is still there; the
`telehealth.ringing` webhook says the same) and then `in_progress`.

```bash
# the LiveKit grant for MY side of the call
curl -X POST "$NATZAR_API/v1/telehealth-consults/$ID/room" -H "Authorization: Bearer $SESSION"
```

```json
{"status": "in_progress", "livekit": {"url": "wss://…", "token": "…", "roomName": "…"}, "consult": {…}}
```

`livekit` is present once the call is live; while the consult is still
`ringing` you get the state without a grant — poll the workspace (or this
route) until it is. The grant is for the **acting** physician; another
physician's consult is `409 not_assigned`.

```bash
# hang up — and stay on the rota (default) or go offline
curl -X POST "$NATZAR_API/v1/telehealth-consults/$ID/end" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"goOffline": false}'
```

```json
{"consult": {"status": "completed", …}, "next": null}
```

`next` is the consult the matcher assigned the moment this one ended, when
a patient was waiting — render it as the next ring without a round trip.
Ending twice is idempotent. The patient rates afterwards (`telehealth.rated`).

## 6. Booked appointments

The physician's agenda comes from published availability
([booking guide](booking.md)) — `PUT`/`PATCH /v1/physicians/me/schedule` are
self-only under a session; a clinician's own calendar edits with the PATCH —
and the patient's booking. The waiting room is a
two-sided beat:

```bash
curl "$NATZAR_API/v1/physicians/me/agenda?from=…&to=…" -H "Authorization: Bearer $SESSION"

# every ~10 s while the physician is in the appointment's waiting room
curl -X POST "$NATZAR_API/v1/telehealth-consults/$ID/ready" \
  -H "Authorization: Bearer $SESSION" -H 'Content-Type: application/json' \
  -d '{"present": true}'
```

```json
{"state": {"phase": "waiting", "startsAt": "…", "otherSidePresent": false}}
```

`state.phase` walks `early` (with `opensAt`) → `waiting` → `connecting` /
`in_progress` → `missed` / `closed`. The LiveKit grant appears the moment
both sides are present inside the window; send `{"present": false}` once
when the physician leaves so the patient's screen stops saying they are
there. Cancelling (`POST …/cancel`) tells the patient immediately.

The agenda answers with `timezone` — the physician's calendar zone — so the
list can be labelled; each consult carries `patientTimezone` when the patient
booked from another zone (show "their time" beside yours).

### The physician's time zone

A physician's calendar is drawn and booked in **one** zone: their own when
they picked one, else the clinic's. Set or clear it on the schedule itself,
before or with the rules it applies to:

```bash
curl -X PATCH "$NATZAR_API/v1/physicians/me/schedule" -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' -d '{"timezone": "America/Toronto"}'
# … and back to the clinic's:
curl -X PATCH "$NATZAR_API/v1/physicians/me/schedule" -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' -d '{"timezone": null}'
```

`GET …/schedule` reports `timezone` (effective), `clinicTimezone`,
`schedulingTimezone` (null = follows the clinic) and `upcomingAppointments`
— the booked appointments that keep their exact instants and re-render on the
new clock after a change. Rules keep their wall-clock meaning. A rule that
names a different `timezone` from the physician's is refused with
`rule_zone_mismatch`; an unknown zone id is `400 invalid_request`.

## 7. The written inbox

The async routes you already know (`replies`, `claim`, `takeover`,
`resolve`, `close`) take any physician credential, plus:

```bash
# from written to video: closes the thread (`escalated`) and invites the patient
curl -X POST "$NATZAR_API/v1/async-consults/$ID/escalate" -H "Authorization: Bearer $SESSION"
```

```json
{"consult": {"status": "closed", "closedReason": "escalated", …}, "telehealthConsultId": "…"}
```

The physician then goes `ready` (§3) to take the call. List filters help
build the piles yourself: `GET /v1/async-consults?assignee=me|unassigned|<id>`
and `origin=partner|platform|any`; `GET /v1/telehealth-consults?status=…&practitionerId=…&mode=…`.

Takeover exists for a patient left waiting past the SLA: it is `409
sla_not_overdue` before that, and `409 thread_not_active` on a thread whose
physician has already answered (nobody is waiting).

## 8. Licences, specialties and languages

```bash
curl -X PUT "$NATZAR_API/v1/physicians/me/licenses" -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' \
  -d '{"licenses": [{"state": "QC", "country": "CA", "licenseNumber": "12345", "expiresAt": "2027-06-30"}]}'

curl "$NATZAR_API/v1/specialties" -H "Authorization: Bearer $SESSION"
curl -X PUT "$NATZAR_API/v1/physicians/me/specialties" -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' \
  -d '{"specialties": ["dermatology"], "acceptsAllSpecialties": false}'

curl -X PUT "$NATZAR_API/v1/physicians/me/languages" -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' \
  -d '{"languages": ["fr", "en"]}'
```

Whether licences are *enforced* in routing is a tenant setting
(`licenseEnforcement` in the licences response, which also lists
`expiringSoon` — surface it: an expired licence silently removes a physician
from the rota); the list is informative otherwise. An empty specialty list
means "any".

**Languages are a ranked preference, never a filter.** Three routing inputs,
three shapes of rule: specialty is a permissive filter, licensure a strict
one, and language is not a filter at all. Among the physicians a consult may
go to after those two, the platform offers it first to one who speaks the
patient's language, then to one who speaks English, then to anyone — in the
live queue, in booking, and in the messaging inbox alike. Inside a tier the
usual fairness order (longest ready, least loaded, fewest open threads)
decides. So no list can ever exclude a physician from a patient, and turning
the feature on can never empty a rota; a physician who records nothing
simply ranks last (never assumed English). Codes are the five base codes
(`en`, `es`, `de`, `fr`, `it` — `PHYSICIAN_LANGUAGES` in the contract, with
display names beside them); a full locale such as `fr_CH` is `400
invalid_request`, because the region says where a *patient* is, not what a
clinician speaks. The read side: every consult carries `patientLang`, the
patient's locale **frozen at mint** (the value routing was decided on, distinct
from `patient.lang`, which moves with the patient's settings), and a slot
grid carries `patientLang` beside each offer's `languages` so a booking page
can badge the times a French speaker is preferred for.

## What a session may call

`GET /v1/physicians/{id}` · presence · heartbeat · availability · workspace ·
agenda · schedule (GET/PUT/PATCH) · licenses (GET/PUT) · specialties (PUT) ·
languages (PUT) ·
`GET /v1/specialties` · telehealth consults (list, get, `room`, `end`,
`ready`, `cancel`) · async consults (list, get, messages, `replies`,
`claim`, `takeover`, `resolve`, `close`, `escalate`) ·
`POST /v1/attachments/upload-urls` · patients (list, get) ·
`GET /v1/agent/messages`.

Everything else — provisioning, creating consults on patients' behalf, embed
sessions, the event log, minting sessions — is the tenant key's, and answers
`403 forbidden` to a session.

## Consults you create for a patient

A consult your server creates (`POST /v1/telehealth-consults`,
`POST /v1/async-consults`) writes a system notice on the patient's
conversation that carries the platform link (`/telehealth?id=`, `/book?id=`
for a scheduled one, `/async?id=` for a written one). The embed widget and
`@natzar/client/patient` turn that link into the **Join / Choose a time /
Accept** button, exactly as they do for a consult the AI agent offered — so
a partner backend and the agent look the same on the patient's screen.
