Patient API Endpoints
All patient endpoints are scoped to the calling principal's context via Row-Level Security. Staff endpoints are mounted under /v1/organizations/{org_id}/patients and gated by patients.view / patients.manage. Patient-side endpoints live under /v1/me/... and resolve via the current_human_patient_profile_ids() RLS helper. The patient-driven sign-up flow lives at /v1/portal/onboard — see onboarding.md.
All IDs are UUIDs. All actor references use principal_id (FK to principals(id)); fields that are semantically humans-only (e.g. patient profile owner) use human_id (FK to humans(principal_id)).
Patient self-service (Portal)
POST /v1/portal/onboard
Patient-driven onboarding — provisions patient_profiles (or reuses an existing one), patients, patient_subscriptions (default tier), and required consent rows in one AdminPool transaction. Mounted outside OrganizationContext. Idempotent on (human_id, organization_id). See onboarding.md → Patient self-service for full detail.
GET /v1/me/patient-profile
Returns the calling human's patient_profiles row (across all clinics — the profile is portable). Patient sees their full profile; the response is empty if the human has never onboarded as a patient.
PATCH /v1/me/patient-profile
Patient updates their own portable profile. Encrypted fields (phone, emergency contact phone) are encrypted at the repo boundary.
GET /v1/me/patient-org-ids
Returns the list of clinics the calling human is a patient at, with each clinic's name + DPO contact info. Used by the portal's "Your clinics" page (1C.3) for DSAR routing self-service.
GET /v1/me/patient-subscription?organization_id={org_id}
Returns the patient's active patient_subscriptions row at a given clinic, with snapshot tier features and limits. 404 if the patient is not at that clinic.
GET /v1/me/consents
Returns the patient's full consent history grouped by (organization_id, purpose_code) with current state + history rows. Foundation 1B.9 trail view.
POST /v1/me/consents/{consent_id}/withdraw
Withdraws a consent that's withdrawable (legal_basis='consent'). Sets withdrawn_at and withdrawn_by_principal_id. Returns 422 if the consent's legal_basis is non-withdrawable (platform_terms, org_terms, etc.) — the response message names the user-facing path ("delete account" or "leave clinic").
Staff endpoints (Clinic admin)
GET /v1/organizations/{org_id}/patients
List patients at a clinic. Gated by patients.view. Server-side paginated, sorted, and filtered per the API conventions.
Query Parameters
page(integer, default: 1) — page number; cap onlimitisapiquery.MaxLimit(500); defaultlimit = 50sort(string, default:-created_at) —created_at,last_used_at, or any column allowed by the per-endpoint allow-listq(string, optional) — typeahead search (matches name + email whenprofile_shared = TRUE; otherwise name only)include(string, optional) —patient_profile,patient_subscription,consents— comma-separated
Response: 200 OK
{
"data": [
{
"id": "33333333-3333-3333-3333-333333333333",
"organization_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"patient_profile_id": "11111111-1111-1111-1111-111111111111",
"profile_shared": true,
"consumer_id": "legacy-123",
"last_used_at": "2026-04-30T08:00:00Z",
"patient_profile": {
"id": "11111111-1111-1111-1111-111111111111",
"human_id": "22222222-2222-2222-2222-222222222222",
"name": "Andrei Popescu",
"date_of_birth": "1985-03-12",
"blood_type": "A+",
"allergies": [],
"chronic_conditions": [],
"insurance_entries": []
},
"created_at": "2026-01-15T10:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total": 87
}
}When profile_shared = false, the embedded patient_profile block is filtered server-side to id, human_id, and name only — the rest of the portable profile (DOB, blood type, etc.) is omitted by the egress classifier (foundation 1A.14).
POST /v1/organizations/{org_id}/patients
Clinic admin onboarding — staff onboards a patient on the patient's behalf. Gated by patients.manage. See onboarding.md → Clinic admin onboarding for full detail.
Request Body
{
"patient_profile": {
"name": "Andrei Popescu",
"email": "[email protected]",
"phone": "+40712345678",
"date_of_birth": "1985-03-12"
},
"consumer_id": "legacy-imported-1234",
"staff_recorded_consents": {
"platform_terms": true,
"org_terms": true,
"org_privacy_notice": true
}
}Errors
400 name_required,400 email_required,400 phone_required,400 invalid_email_format403 forbidden— caller lackspatients.manage409 patient_already_exists— the human is already a patient at this org422 consent_required—staff_recorded_consentsdid not include all required platform + org consents
GET /v1/organizations/{org_id}/patients/{patient_id}
Get a single patient at this org. Gated by patients.view. RLS scopes the result; 404 returned for cross-org access.
Query Parameters
include(string, optional) —patient_profile,patient_subscription,consents,custom_field_values
Response: 200 OK
{
"data": {
"id": "33333333-3333-3333-3333-333333333333",
"organization_id": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"patient_profile_id": "11111111-1111-1111-1111-111111111111",
"profile_shared": true,
"consumer_id": "legacy-123",
"patient_profile": {
"id": "11111111-1111-1111-1111-111111111111",
"human_id": "22222222-2222-2222-2222-222222222222",
"name": "Andrei Popescu",
"date_of_birth": "1985-03-12",
"sex": "male",
"occupation": "Inginer",
"residence": "București",
"blood_type": "A+",
"allergies": ["Penicilină"],
"chronic_conditions": [],
"emergency_contact_name": "Maria Popescu",
"insurance_entries": [
{ "provider": "Romanian Health Insurance House", "number": "RO-123456", "type": "national" }
]
},
"custom_field_values": {
"referral_source": "Google",
"training_surface": "grass"
},
"created_at": "2026-01-15T10:00:00Z",
"updated_at": "2026-01-15T10:00:00Z"
}
}When profile_shared = false, the patient_profile block is filtered to id, human_id, and name server-side. The patient themselves always sees their full own profile via /v1/me/patient-profile.
Errors
404 not_found— patient doesn't exist or RLS filtered the row
PATCH /v1/organizations/{org_id}/patients/{patient_id}
Update org-scoped patient fields (the patients row only — portable profile fields update via /v1/me/patient-profile).
Request Body
{
"consumer_id": "updated-external-id"
}Fields
consumer_id(string, optional) — external system identifierprofile_sharedis not editable here — it flips automatically when the patient grants/withdraws theprofile_sharingconsent in foundation 1B.9
Errors
403 forbidden— caller lackspatients.manage404 not_found
DELETE /v1/organizations/{org_id}/patients/{patient_id}
Soft-delete the per-org link. Sets deleted_at on the patients row. The portable patient_profiles row is never hard-deleted (GDPR Art. 17(3)(c) — anonymisation, not deletion). Gated by patients.manage.
Response: 200 OK
{
"data": {
"id": "33333333-3333-3333-3333-333333333333",
"deleted_at": "2026-04-30T12:00:00Z"
}
}Cascade
The DELETE triggers withdrawal of every org-scope consent at this org (org_terms, org_privacy_notice, marketing toggles, etc.) — the consent ledger gets withdrawn_at set with withdrawal_reason='patient_left_clinic'. Platform-scope consents are unaffected. The patient_profiles row stays intact since the patient may still be active at other clinics.
GET /v1/organizations/{org_id}/patients/{patient_id}/consents
Returns the patient's consent history at this clinic — grouped by purpose_code, with current state and full history. Gated by consents.view_org. Used by the per-patient consents view in the Clinic admin UI (1C.2).
Patient impersonation (clinic-side)
Clinic staff acting on a specific patient's behalf — assisted form fill, accessibility help, language assistance, verbal-consent capture, technical support. Schema, middleware, and audit instrumentation live in foundation 1B.13. Full feature reference in impersonation.md.
Distinct from the platform-staff break-glass flow (foundation 1B.11) which gates cross-tenant platform access. Impersonation lives entirely within the clinic's controllership — clinic staff acting on the clinic's own patient.
Single permission: patients.impersonate — granted by default to admin + customer_support; clinics can grant to custom roles via the role editor. Admin oversight reads piggyback on the existing patients.manage permission.
Endpoints (foundation 1B.13)
POST /v1/organizations/{org_id}/patient-impersonation-sessions— open (gated bypatients.impersonate; rate-limited via 1A.13)POST /v1/organizations/{org_id}/patient-impersonation-sessions/{session_id}/close— close (opening principal orpatients.manage)GET /v1/organizations/{org_id}/patient-impersonation-sessions— list (gated bypatients.manage)GET /v1/me/access-history?organization_id={org_id}— patient sees own session history
Audit attribution: actor_id = staff principal, impersonation_id = session UUID, action_context = 'impersonation'. The patient appears in the session record (target_patient_id), not in actor_id. Same regulator-friendly framing as break-glass.
Example workflows
Patient self-onboards via portal
# 1. Clerk sign-up completes; portal calls onboard
POST /v1/portal/onboard
Authorization: Bearer <clerk_jwt>
X-Organization-ID: 9f8e7d6c-5b4a-3210-fedc-ba9876543210
{
"patient_profile": { "name": "Andrei Popescu", "phone": "+40712345678" },
"consent_grants": {
"platform_terms": true,
"platform_privacy_notice": true,
"org_terms": true,
"org_privacy_notice": true,
"marketing_email": false,
"analytics": true
}
}
# 2. Patient flips a marketing toggle later from settings
POST /v1/me/consents
{ "purpose_code": "marketing_email", "granted": true }
# 3. At first appointment booking, the booking flow calls RequireConsent('telemedicine')
# — if not yet granted, returns 403 consent_required, the booking UI renders
# the telemedicine form (Tier B medical consent), patient signs it.
# See F3.5 for the form-driven path.Clinic admin onboards a phone-booking patient
POST /v1/organizations/9f8e7d6c-5b4a-3210-fedc-ba9876543210/patients
Authorization: Bearer <staff_token>
{
"patient_profile": {
"name": "Maria Ionescu",
"email": "[email protected]",
"phone": "+40712000000"
},
"staff_recorded_consents": {
"platform_terms": true,
"org_terms": true,
"org_privacy_notice": true
}
}
# Patient receives a Clerk invite by email. On first portal sign-in,
# the re-consent middleware (1B.9) prompts them to accept the
# platform_privacy_notice + any optional toggles they want.Related endpoints
| Concern | Endpoint | Notes |
|---|---|---|
| Custom field values per patient | GET/PUT /v1/organizations/{org_id}/patients/{patient_id}/profile | Org-specific data — see Custom Fields API (F3) |
| Form prefill | GET /v1/organizations/{org_id}/patients/{patient_id}/prefill?keys=... | Returns custom-field values for a form — see F3 |
| Segments | GET /v1/organizations/{org_id}/patients/{patient_id}/segments | Which segments the patient belongs to — see F8 Segments |
| Appointments | GET /v1/organizations/{org_id}/appointments?patient_id={patient_id} | F5 |
| Caregiver onboarding | (deferred) | F1B.6 schema is in place; admin endpoint pending a real use case |