@patientos/public-sdk
v0.1.0
Published
Typed browser/node client for the PatientOS public booking + service-request API (/api/public).
Maintainers
Readme
@patientos/public-sdk
A small, typed, zero-dependency client for the PatientOS public API
(/api/public/*). Embed it on a clinic's own website to run the patient-facing
flows — appointment booking and service requests (e.g. an online medical
certificate) — against the clinic's receptionist domain.
It works in the browser and in Node (SSR / tests). The transport, auth headers, token plumbing, and the error contract live in one place so each call is a thin, typed method.
Install
bun add @patientos/public-sdkQuick start
import { createPatientOSClient } from '@patientos/public-sdk'
const client = createPatientOSClient({
baseUrl: 'https://clinic.example.com', // the clinic's receptionist domain
publishableKey: 'rcp_pk_…', // the registered origin's publishable key
})
// 1. List what the clinic offers (non-PHI, cacheable).
const services = await client.listServices()
const cert = services.find((s) => s.key === 'medical-certificate')!
// 2. Establish the visitor's identity → a claim token, held by the client.
await client.startSession({ fullName: 'Pat Demo', phone: '+61400111222' })
// 3. Start the request, capture answers (with a LIVE safety-gate preview), submit.
const { requestId } = await client.startRequest({ serviceKey: cert.key })
const { gate } = await client.saveAnswers(requestId, { reason: 'Flu', days_off: 2 })
if (gate?.verdict === 'escalate') {/* show the red-flag message, stop */}
const { status } = await client.submitRequest(requestId) // 'awaiting_slot' once the fee is HELD
// 4. Pick a slot, hold it, and book.
const { slots } = await client.getAvailability({ appointmentTypeId: cert.appointmentTypeId! })
const slot = slots[0]
const hold = await client.createHold({
practitionerId: slot.practitionerId,
appointmentTypeId: cert.appointmentTypeId!,
slotStart: slot.start,
serviceRequestId: requestId,
})
const booked = await client.bookRequest(requestId, {
practitionerId: slot.practitionerId,
slotStart: slot.start,
holdId: hold.holdId,
holderToken: hold.holderToken,
})
// Verify a certificate later (tenant-less — the token is the credential).
const result = await client.verify('…token from the QR code…')How auth works
Tenant resolution is by the Origin header + publishable key, never a
session. The clinic registers each embedding origin and issues it a publishable
key (safe to ship to the browser). Requests from an unregistered origin are
refused, and the response carries no PHI.
- In the browser, the
Originheader is set by the browser automatically. - In Node / SSR / tests, pass
originso the request carries one:createPatientOSClient({ baseUrl, publishableKey, origin: 'https://clinic.example.com', fetch })
The claim token (from startSession) is held on the client and attached to
request-scoped calls as x-rcp-claim-token. Persist it across reloads with
getClaimToken() / setClaimToken(). Hold tokens (from createHold) are
passed back into bookRequest / releaseHold.
Errors
Every non-2xx response is a stable { error: "<code>" } — never internal text or
PHI. The SDK surfaces that as a thrown PatientOSApiError (.status,
.code, .path); branch on err.code ('unknown_service',
'origin_not_registered', 'slot_taken', …). A pre-HTTP failure (network down,
CORS block, abort) throws PatientOSTransportError.
Example site
examples/booking-widget is a runnable "clinic website" that embeds this SDK to
drive the full medical-certificate flow. It's served on :3000 (a seeded
registered origin) and is exercised in a real browser by
apps/web/tests/e2e/sdk-widget.spec.ts, which proves the cross-origin path
(CORS preflight, ACAO reflection, the whole flow) against the live worker.
Not yet included (next cut)
This package is the headless client — the foundation. Planned on top of it:
embed.js— a single<script>tag that auto-mounts a prebuilt widget (no build step on the clinic's side).- Builder components — React
Booking/CertRequestcomponents for the in-app website builder. - A question-set schema read.
GET /servicesreturns a service'squestionSetIdbut not the question definitions, so a widget can't yet render an arbitrary clinic's intake form generically — the example hard-codes the med-cert questions to match the seed. A publicGET /services/:key(or a question-set read) that returns the non-PHI question schema is the unlock.
