Patient Portal
Public kiosk API for self-service booking and results.
The Patient Portal is a public, unauthenticated kiosk surface (a clinic iPad in the
waiting room). There is no session — every request is scoped by the clinic slug in
the URL. The matching frontend lives at /portal/{clinic} and renders with no sidebar or
auth guard.
| Method | Path | Notes |
|---|---|---|
GET | /api/portal/{clinic} | Clinic name for the kiosk header |
GET | /api/portal/{clinic}/link | Relay-based pairing descriptor for the wallet app |
GET | /api/portal/{clinic}/doctors | Public list of the clinic's doctors |
GET | /api/portal/{clinic}/availability | Taken time slots for a doctor on a day |
POST | /api/portal/{clinic}/patients | Register a new (demographics-only) patient |
POST | /api/portal/{clinic}/appointments | Self-service booking |
GET | /api/portal/{clinic}/results | Minimal status lookup |
The portal is reachable two ways: patients open the web page at /portal/{clinic}, or they
scan the clinic's Patient Portal QR (Settings → Signing → Patient Portal, or the portal's
own "Link my wallet" card). The web page uses the JSON endpoints below directly; the
Temetro wallet app instead connects over the Temetro Network relay and runs the same
actions there (so it works from any phone without the clinic exposing its API).
Wallet pairing link
GET /api/portal/{clinic}/link returns the non-secret descriptor the wallet app needs to
reach this clinic over the relay: the clinic's Ed25519 signing public key (the relay's
routing id) and the relay URL. The frontend encodes it into a temetro-portal: QR.
{
"clinicId": "<clinic signing public key, hex>",
"relay": "https://network.temetro.com",
"slug": "sunrise-clinic",
"name": "Sunrise Clinic"
}After scanning, the wallet app links by its wallet number — the clinic attaches that number to the patient's file ahead of time (via Import from a patient app / QR pairing), so the patient types nothing. It then books appointments and downloads results over the relay — all attributed to that patient on the clinic's web app. (If the wallet isn't paired yet, linking returns a friendly "ask the front desk to add your wallet number" message.)
These endpoints are public. PHI exposure is deliberately minimal: every lookup requires both a file number and a matching name, and results return only appointment status and whether results exist — never clinical values. A per-kiosk token or one-time verification code is the recommended hardening for a real deployment.
List doctors
GET /api/portal/{clinic}/doctors returns the clinic's providers so a patient can choose
who to see. Only display-safe fields leave this public surface — no ids, emails, or
usernames.
[
{ "name": "Dr. Amina Yusuf", "specialty": "Cardiology" },
{ "name": "Dr. Omar Ali", "specialty": null }
]Check availability
GET /api/portal/{clinic}/availability?provider=Dr.%20Amina%20Yusuf&date=2026-07-01 returns
the taken time slots for that provider on that day, so the portal (and the wallet app)
can render only free times.
{ "date": "2026-07-01", "provider": "Dr. Amina Yusuf", "taken": ["09:30", "11:00"] }The filter mirrors the booking conflict check (an appointment with no provider blocks the
slot clinic-wide). Booking still re-validates server-side, so a slot taken between the
availability check and the booking is rejected with 409.
Register a new patient
POST /api/portal/{clinic}/patients lets a first-time visitor create a record so they can
then book. It writes demographics only (no clinical PHI) from this unauthenticated
surface.
{ "name": "Hodan Warsame", "sex": "F", "age": 34 }Returns 201 with { "fileNumber": "10005", "name": "Hodan Warsame" }. The kiosk then
books using that file number.
Book an appointment
POST /api/portal/{clinic}/appointments
{
"fileNumber": "10293",
"name": "Hodan Warsame",
"date": "2026-07-01",
"time": "09:30",
"type": "Follow-up",
"provider": "Dr. Amina Yusuf"
}provider is optional — the chosen doctor from the picker; when omitted it falls back to the
patient's primary provider. The booking is verified against an existing patient
(fileNumber + matching name) in that clinic, must be a future date, and is rejected with
409 if the slot is already taken (same date + time for the same provider). On success it creates a confirmed appointment
that appears on
the clinic's Appointments page. Returns 201 with the confirmed
date, time, type, and provider. Unknown name/file number → 404.
View results
GET /api/portal/{clinic}/results?fileNumber=10293&name=Hodan%20Warsame
{
"name": "Hodan Warsame",
"upcoming": [
{ "date": "2026-07-01", "time": "09:30", "type": "Follow-up", "provider": "Dr. Amina Yusuf", "status": "confirmed" }
],
"hasResults": true,
"resultCount": 3
}Returns the patient's upcoming appointments plus whether results are on file. It never returns lab values — the kiosk tells the patient to ask a staff member to review results with them.