The clinician screens in web/context call the MARMAR backend under /api/clinician and the clinician Better Auth instance under /api/auth/clinician. The backend's shapes win: src/api/wire.ts mirrors its JSON field for field (from src/clinician/** in marmar-cds), and the adapters (adapt.ts, adapt-checkins.ts) turn it into the view types the screens read (types.ts and its parts). The typed client is client.ts. The backend's own guides are docs/context-backend.md, docs/context-api.md and docs/clinician-sharing-and-check-ins.md; its machine-readable contract is GET /api/clinician/openapi.json.
test/fixture answers every route below in the same wire shapes with fictional data (npm run fixture), so fixture mode runs through the same client and adapters. If this page and wire.ts disagree, the backend wins: change wire.ts, the adapter and the fixture together.
/api (CONTEXT_API_PROXY), and a hosted site proxies /api/clinician and /api/auth/clinician to the backend. Or set VITE_CONTEXT_API_ORIGIN at build time to call the backend directly; it then needs the site's origin in CLINICIAN_ALLOWED_ORIGINS (the dev server's http://127.0.0.1:4190 and http://localhost:4190 are allowed outside production). Every call sends cookies (credentials: "include"); every write needs a Context Origin.{ code, message }; validation is 400 VALIDATION_ERROR with details: [{ path, message }]; some sharing errors add fields (eligibility, phone), which the client puts in ApiError.details. A switched-off area answers 404 <AREA>_NOT_ENABLED; the client reads the invitations, alerts and check-in lists as empty then.SIGN_IN_REQUIRED, SESSION_EXPIRED or ACCOUNT_CLOSED go to sign-in. 403 STEP_UP_REQUIRED or SESSION_LOCKED show the lock screen; 403 TERMS_REQUIRED shows the terms; 403 CLINICIAN_SUSPENDED or VERIFICATION_LAPSED go to sign-in, which says why./check-ins/:id/updates, /alerts) send X-Context-Background: 1, so they do not keep the session from locking.| Method and path | Body | Answer | View |
|---|---|---|---|
POST /api/auth/clinician/email-otp/send-verification-otp |
{ email, type: "sign-in" } |
{ success }, whoever the email is |
|
POST /api/auth/clinician/sign-in/email-otp |
{ email, otp } |
sets the session cookie | |
POST /api/auth/clinician/sign-out |
{} |
ends this session | |
GET /me |
{ clinician, session: { stepUpDone, locked, idleLockMinutes, startedAt, endsAt }, secondFactor: { totp }, features } |
ClinicianSession (stepUp: enrol with no authenticator, done, else required) and ClinicianProfile |
|
POST /step-up/totp |
201 { secret, otpauthUrl, qrSvg } (the QR is drawn by the backend) |
TotpEnrolment |
|
POST /step-up/totp/confirm |
{ code } |
{ stepUpDone, recoveryCodes }: ten codes, shown once |
TotpConfirmed |
POST /step-up/verify |
{ code }: 6 digits or a recovery code |
{ stepUpDone, method } |
the session again |
POST /session/lock |
{ locked: true } |
the session again | |
POST /me/terms |
{ termsVersion, dpaVersion } |
{ clinician } |
the session again |
POST /sessions/sign-out-everywhere |
{ signedOut } |
PATCH /me (step-up) takes { title, displayName, pronoun, mobilePhone, idleLockMinutes } and answers { clinician }. The name on the register (fullName) is not editable; Context splits it after the title. Browser notifications are kept in this browser, not on the server. GET /clinic answers { clinic } with addressLine, timezone, openingHours and alertWatchHours (days 1 to 7), backupAlertName/Phone/Email, emergencyNumbers (source: clinic | registry, testDialledAt), role and version. PATCH /clinic is the owner's only (403 CLINIC_OWNER_ONLY), takes expectedVersion (409 CLINIC_CHANGED), and never gets the registry's numbers back.
| Method and path | Body or answer |
|---|---|
POST /access-requests (public) |
{ fullName, profession, licenceBody, licenceNumber, countryCode, facilityName, facilityPhone, workEmail, mobilePhone, heardFrom, heardFromDetail?, acceptTerms: true, termsVersion, inviteCode? } → 202 { status: "received", message } whatever is on file |
GET /invite-preview/:token (public) |
{ status, patientName, withPerson, doctorName, clinicName, doctorEmail, expiresAt }; no pronoun, so Context says they/them |
GET /invitations |
{ invitations: [{ id, patientName, patientAge, withPerson, scopes, allowsCheckInCalls, withheldSome, sentAt, expiresAt, possibleMatch }] } |
POST /invitations/:id/accept |
{ confirmedMyPatient: true, linkToPatientId } → { patientId, shareId } |
POST /invitations/:id/decline |
{ reason: not_my_patient | not_for_context | other | null, note } → { declined: true }; the note is not kept |
| Method and path | Answer |
|---|---|
GET /patients |
{ items, asOf }, sorted by what needs attention. Each connected patient carries the tile: sharing.sharedBy, medicines: { count }, doses: { taken, expected, onTime } and lastCheckIn: { at, kind, status }, each null for an area not shared (the tile reads "Not shared") and all null while paused |
POST /patients/search |
{ query } → the same { items, asOf } (names never go in a URL) |
POST /patients |
{ displayName, dateOfBirth | ageYears, sex?, attestation: { careRelationship: true, patientAgreement: true, version: "2026-10-30" } } → 201 { patient } (the bare row); the client then reads the detail |
GET /patients/:id |
{ patient }: the item plus firstName, dateOfBirthIso, identityOnFile, timeZone (the clinic's), hasOwnAccount, phone, pronoun, activeCheckInId |
GET /patients/:id/shared |
The tile: { asOf, sharing: { sharedBy, scopes, allowsCheckInCalls, withheldSome, … }, medicines, doses, lastCheckIn: { at, kind, status }, phone }. GET /patients, search and GET /patients/:id carry the same medicines, doses, lastCheckIn and sharing.sharedBy for each connected patient while sharing is on |
POST /patients/:id/viewed |
{ section } → { lastLookedAt } (the previous look) |
GET /patients/:id/activity |
{ events: [{ id, at, actor, actorKind, action }] } |
For a connected patient GET /patients/:id carries hasOwnAccount, phone, firstName, language and activeCheckInId; the client still reads the tile, GET /patients/:id/check-ins/plan (eligibility, limits, quiet hours, blocked times, the patient's zone) and GET /patients/:id/check-ins for the rest.
GET /patients/:id/shared/medicines | doses | check-ins | records answer as SharedMedicines and friends (doses adds complete); 403 SHARING_PAUSED, SHARING_STOPPED or SCOPE_NOT_SHARED, 404 PATIENT_NOT_VISIBLE. prescriber is null until the app stores one.
| Method and path | Body | Answer |
|---|---|---|
GET /patients/:id/records |
{ records }: each with reading, readingError, readingErrorCode, heldBack: { reason } | null, clarification, and fileName: null (names are not kept) |
|
POST /patients/:id/records/uploads |
{ fileName, contentType, sizeBytes } |
201 { uploadId, uploadUrl, method, headers, expiresAt, maxBytes } |
PUT <uploadUrl> |
the file | storage's own answer |
POST /patients/:id/records |
{ text } or { uploadId }, title, type, place, recordDate, origin |
201, the record |
PATCH /records/:id |
{ title, type, place, recordDate } |
the record |
POST /records/:id/confirm-patient |
the record, no longer held back | |
POST /records/:id/read-again |
the record (a failed file) | |
DELETE /records/:id |
{ deleted: true, removedAt, reading: read_again | set_aside | unchanged }: a soft delete; its facts leave review and the next check. Context asks to confirm first |
|
GET /records/:id/lines |
{ recordId, lines: [{ number, text, page, box, confidence, hardToRead, handwritten }] } |
POST /patients/:id/extractions (201), GET /patients/:id/extractions/latest ({ extraction }), GET /extractions/:id: the preview's Draft plus revision, records: [{ recordId, status (incl. held_back), error, inReading }] (no titles: the client reads them from the records list), recordsNotReady, decisions, coverageReview, acknowledgedOriginals, confirmedHardToRead, stale. Facts carry hardToRead; coverage candidates carry kind (medicine, allergy, kidney).
PUT /extractions/:id/decisions takes { revision, decisions, coverageReview: { id: { choice: not_current \| not_relevant, reason? } }, acknowledgedOriginals, confirmedHardToRead } → { revision, savedAt }; 409 STALE_REVISION from an older tab. A reason is needed for an allergy (10 characters or more) or with no choice. An included Hard to read fact must be confirmed one by one, never in bulk. POST /extractions/:id/clarifications (201) answers the new reading.
| Method and path | Answer |
|---|---|
POST /patients/:id/checks { extractionId, revision } |
201 CheckRun: the two-pass assessment, summary, details, marks, missingFactors, monitoring, notChecked (with lines), urgentReview, versions (with signedOffOnly), checkedOn, latest, newInformation, informationTool, recordTitles, sourceLines. 409 RECORDS_NOT_READY, STALE_EXTRACTION, STALE_REVISION; 400 with the open gate's code |
GET /patients/:id/checks |
{ checks: [{ id, ranAt, ranBy, findings, added }] } |
GET /checks/:id |
CheckRun |
POST /checks/:id/findings/:findingId/mark { kind, note, reason } |
201 FindingMark; 400 REASON_REQUIRED or DESCRIPTION_REQUIRED |
GET /checks/:id/export?format=json|html|pdf |
JSON as an attachment; html and pdf both answer the printable page (X-Export-Format: html), which Context opens in a new tab to print or save as a PDF |
| Method and path | Body | Answer |
|---|---|---|
GET /patients/:id/check-ins/plan?checkId=&findingId= |
The plan for the latest check, or checkId's, with findingId's questions first: eligibility { ok, code, message }, urgentFirst, the fixed lines, suggestions, closings (sent, read_today, see_in_clinic, call_by_5, safety_net), window (days 1 to 7; quietHours null when off; only zones for a clinic-added patient), limits (null when not eligible), fairUse, maxQuestions (10, own included). Linked findings go in the create body |
|
POST /check-ins/questions/validate |
{ patientId, text } |
{ ok: true, text } or { ok: false, rule, reason } |
POST /patients/:id/check-ins |
{ questions, closingIds, window, expiresInHours, linkedFindingIds, checkId, urgentCallConfirmedAt, readToday } |
201 CheckIn. 409 CHECK_IN_NOT_ALLOWED (eligibility), URGENT_CALL_FIRST (phone), NO_WINDOW; 429 LIMIT_REACHED |
GET /patients/:id/check-ins |
{ checkIns } for this clinician |
|
GET /check-ins/:id |
CheckIn: plan (with linkedTiers), script, status, history, window, later, urgent, urgentAcknowledgedAt, notes (with validation, endedEarly), summary, transcript, alerts (already sent: urgent sent, escalated, not reached), lastEventId |
|
GET /check-ins/:id/events |
Server-sent events, replayed from Last-Event-ID or ?lastEventId= |
|
GET /check-ins/:id/updates?after=N |
{ events, lastEventId } |
|
POST /check-ins/:id/read |
{ readAt } |
|
POST /check-ins/:id/notes/:noteId/acknowledge |
The note; every urgent note of the check-in is acknowledged | |
POST /check-ins/:id/add-to-check |
{ noteIds } |
{ recordId, notes, note }: the chosen notes become one record from the call. Context sends its whole choice here; 409 NOTE_NOT_CONFIRMED for a note not found in the call text or disputed |
POST /check-ins/:id/notes/:noteId/add-to-check |
{ note, recordId }, one note on its own (a record each time) |
|
POST /check-ins/:id/cancel |
CheckIn; 409 CHECK_IN_NOT_OPEN |
|
GET /alerts |
{ alerts }, one per check-in with an urgent note not yet acknowledged (noteId may be null) |
Statuses (YCI-28): sent, ringing, missed, later, in_progress, completed, declined, not_reached (expired without a call), identity_not_confirmed, ended_early, cancelled. Urgent is a flag (urgent, with the urgent note), never a status. Status detail is a code (call_started, cancelled_by_clinician).
Events: status, note, note_updated, transcript, summary, urgent, and alert ({ kind: urgent_sent | escalated | not_reached, message }), which Context shows as a banner on the live panel. Context folds events with applyEvent (src/logic/checkin-events.ts), ignoring any id at or below the last one seen; two stream errors in a minute switch to polling every 3 seconds.