Skip to content

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 on limit is apiquery.MaxLimit (500); default limit = 50
  • sort (string, default: -created_at) — created_at, last_used_at, or any column allowed by the per-endpoint allow-list
  • q (string, optional) — typeahead search (matches name + email when profile_shared = TRUE; otherwise name only)
  • include (string, optional) — patient_profile, patient_subscription, consents — comma-separated

Response: 200 OK

json
{
  "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

json
{
  "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_format
  • 403 forbidden — caller lacks patients.manage
  • 409 patient_already_exists — the human is already a patient at this org
  • 422 consent_requiredstaff_recorded_consents did 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

json
{
  "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

json
{
  "consumer_id": "updated-external-id"
}

Fields

  • consumer_id (string, optional) — external system identifier
  • profile_shared is not editable here — it flips automatically when the patient grants/withdraws the profile_sharing consent in foundation 1B.9

Errors

  • 403 forbidden — caller lacks patients.manage
  • 404 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

json
{
  "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 by patients.impersonate; rate-limited via 1A.13)
  • POST /v1/organizations/{org_id}/patient-impersonation-sessions/{session_id}/close — close (opening principal or patients.manage)
  • GET /v1/organizations/{org_id}/patient-impersonation-sessions — list (gated by patients.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

bash
# 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

bash
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.

ConcernEndpointNotes
Custom field values per patientGET/PUT /v1/organizations/{org_id}/patients/{patient_id}/profileOrg-specific data — see Custom Fields API (F3)
Form prefillGET /v1/organizations/{org_id}/patients/{patient_id}/prefill?keys=...Returns custom-field values for a form — see F3
SegmentsGET /v1/organizations/{org_id}/patients/{patient_id}/segmentsWhich segments the patient belongs to — see F8 Segments
AppointmentsGET /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