Skip to main content

Patient Portal Service — API Contracts

Status: populated Owner: TBD Last updated: 2026-04-18 Companion: Service Template · 03 platform-services · 02 DDD

1. Auth Model

All portal endpoints require a Keycloak-issued JWT (patient realm) with the relevant SMART on FHIR scope.

HeaderRequiredNotes
Authorization: Bearer <jwt>YesPatient JWT from Keycloak patient realm
X-Tenant-IDYesResolved from JWT tid claim; must match portal account
Accept-LanguageNoBCP 47 tag; drives localized display values

Anonymous access is not permitted. All endpoints return 401 without a valid token and 403 MODULE_NOT_LICENSED if the tenant's ehr.portal entitlement is absent.


2. Account & Session

2.1 Get Current Account

GET /v1/portal/me

Scope: openid profile patient/Patient.read

Response 200:

{
"accountId": "pact_01JXXX",
"patientId": "pat_01JXXX",
"status": "active",
"mfaEnabled": true,
"preferredLanguage": "en",
"lastLoginAt": "2026-04-18T08:00:00Z"
}

Errors: 401, 403 ACCOUNT_NOT_ACTIVE, 403 MODULE_NOT_LICENSED


2.2 Get Access Log

GET /v1/portal/me/access-log

Query params: ?limit=20&offset=0&from=ISO8601&to=ISO8601

Scope: patient/Patient.read

Response 200:

{
"data": [
{
"id": "paev_01JXXX",
"eventType": "result.viewed",
"resourceType": "Observation",
"occurredAt": "2026-04-18T09:00:00Z"
}
],
"total": 14,
"limit": 20,
"offset": 0
}

3. Health Record Read Surfaces (FHIR Projections)

3.1 Patient Summary

GET /v1/portal/chart/summary

Scope: patient/Patient.read

Returns a composite FHIR Bundle aggregating Patient demographics, active conditions (problem list), and allergy summary from upstream services.

Response 200: FHIR Bundle (type: document)


3.2 Chart Section

GET /v1/portal/chart/{section}

Path param: section = allergies | medications | vitals | immunizations | problems | documents

Scope: patient/AllergyIntolerance.read | patient/MedicationRequest.read | patient/Observation.read | patient/Immunization.read | patient/Condition.read | patient/DocumentReference.read

Query params: ?limit=50&offset=0

Response 200: FHIR Bundle (type: searchset) of relevant resource type.

Errors: 400 INVALID_SECTION, 403 INSUFFICIENT_SCOPE


3.3 Lab Results

GET /v1/portal/results/lab

Scope: patient/Observation.read

Query params: ?from=ISO8601&to=ISO8601&status=final|preliminary&limit=50&offset=0

Only observations where result release policy = patient-visible are returned.

Response 200: FHIR Bundle (type: searchset) of Observation resources.


3.4 Radiology Results

GET /v1/portal/results/radiology

Scope: patient/DiagnosticReport.read

Query params: ?from=ISO8601&to=ISO8601&limit=20&offset=0

Only reports released per result-release policy.

Response 200: FHIR Bundle (type: searchset) of DiagnosticReport resources.


3.5 Immunizations

GET /v1/portal/immunizations

Scope: patient/Immunization.read

Response 200: FHIR Bundle of Immunization resources.


4. Appointments

4.1 List Appointments

GET /v1/portal/appointments

Scope: patient/Appointment.read

Query params: ?status=booked|pending|cancelled|fulfilled&from=ISO8601&to=ISO8601&limit=20&offset=0

Response 200: FHIR Bundle (type: searchset) of Appointment resources.


4.2 Request New Appointment

POST /v1/portal/appointments/request

Scope: patient/Appointment.write

Request body:

{
"serviceType": "{ FHIR CodeableConcept }",
"preferredProviderId": "prov_01JXXX",
"preferredFacilityId": "fac_01JXXX",
"requestedStart": "2026-05-01T09:00:00Z",
"requestedEnd": "2026-05-01T09:30:00Z",
"reason": "Follow-up for hypertension management",
"notes": "Prefer morning"
}

Response 201:

{
"requestId": "apptreq_01JXXX",
"status": "pending",
"submittedAt": "2026-04-18T10:00:00Z"
}

Errors: 400 INVALID_REQUEST, 409 SLOT_UNAVAILABLE, 503 UPSTREAM_UNAVAILABLE


4.3 Cancel Appointment

POST /v1/portal/appointments/{appointmentId}/cancel

Scope: patient/Appointment.write

Request body: { "reason": "string" }

Response 200: { "appointmentId": "...", "status": "cancelled" }


5. Medications

5.1 List Medication Requests

GET /v1/portal/medications

Scope: patient/MedicationRequest.read

Query params: ?status=active|completed|stopped&limit=50&offset=0

Response 200: FHIR Bundle (type: searchset) of MedicationRequest resources.


5.2 Request Medication Refill

POST /v1/portal/medications/{medicationRequestId}/refill

Scope: patient/MedicationRequest.write

Request body: { "pharmacyId": "...", "notes": "string" }

Response 201:

{
"refillRequestId": "rxreq_01JXXX",
"status": "pending",
"submittedAt": "2026-04-18T10:00:00Z"
}

6. Billing & Coverage

6.1 Get Coverage

GET /v1/portal/billing/coverage

Scope: patient/Coverage.read

Response 200: FHIR Bundle of Coverage resources.


6.2 Get Explanation of Benefits

GET /v1/portal/billing/eob

Scope: patient/ExplanationOfBenefit.read

Query params: ?from=ISO8601&to=ISO8601&limit=20&offset=0

Response 200: FHIR Bundle of ExplanationOfBenefit resources.


7. Proxy Delegation

7.1 List My Delegations (as grantor)

GET /v1/portal/proxy/delegations

Scope: patient/Patient.read

Response 200:

{
"data": [
{
"delegationId": "pdel_01JXXX",
"proxyAccountId": "pact_01JYYY",
"relationshipType": "parent",
"scope": ["read:record", "read:results", "read:appointments"],
"validFrom": "2026-01-01",
"validTo": null,
"status": "active"
}
]
}

7.2 Grant Proxy Delegation

POST /v1/portal/proxy/delegations

Scope: patient/Patient.write

Request body:

{
"proxyPortalAccountId": "pact_01JYYY",
"relationshipType": "guardian",
"scope": ["read:record", "read:appointments"],
"validFrom": "2026-04-18",
"validTo": "2027-04-18"
}

Response 201: { "delegationId": "pdel_01JXXX", "status": "active" }

Errors: 409 DELEGATION_ALREADY_EXISTS, 400 INVALID_SCOPE


7.3 Revoke Proxy Delegation

DELETE /v1/portal/proxy/delegations/{delegationId}

Scope: patient/Patient.write

Response 200: { "delegationId": "...", "status": "revoked" }


8. FHIR Export

8.1 Request PHR Export

POST /v1/portal/export

Scope: patient/Patient.read (all sub-scopes implicitly required)

Request body:

{
"format": "application/fhir+ndjson",
"includeResources": ["Patient", "Condition", "Observation", "MedicationRequest", "Immunization", "AllergyIntolerance", "Appointment"],
"from": "2020-01-01",
"to": "2026-04-18"
}

Response 202:

{
"exportJobId": "expjob_01JXXX",
"status": "in-progress",
"contentLocation": "/v1/portal/export/expjob_01JXXX/status"
}

8.2 Poll Export Status

GET /v1/portal/export/{exportJobId}/status

Response 200 (complete):

{
"exportJobId": "expjob_01JXXX",
"status": "complete",
"downloadUrl": "/v1/portal/export/expjob_01JXXX/download",
"expiresAt": "2026-04-19T10:00:00Z"
}

9. Secure Messaging (if licensed)

9.1 List Message Threads

GET /v1/portal/messages

Scope: patient/Communication.read — requires ehr.messaging license

Response 200: { "data": [ { threadId, subject, unread, lastMessageAt } ], "total": N }

9.2 Read Thread

GET /v1/portal/messages/{threadId}

Scope: patient/Communication.read

Response 200: Thread with message array.


10. AI Navigation Assistant

10.1 Portal Chat Completions

POST /v1/portal/ai/navigate

Scope: patient/Patient.read — requires ai.patient-assistant feature flag

Request body:

{
"message": "Where can I find my last blood test results?",
"sessionId": "sess_01JXXX"
}

Response 200:

{
"reply": "Your most recent lab results are under Results > Lab. Click here to navigate.",
"navigationHint": "/v1/portal/results/lab",
"tier": "A",
"moderated": true
}

Constraints: Non-diagnostic only. Response moderation applied via ai-gateway-service. No PHI in prompts.


11. Common Error Codes

HTTPCodeDescription
400INVALID_REQUESTMalformed request body or invalid params
401UNAUTHORIZEDMissing or invalid JWT
403INSUFFICIENT_SCOPEJWT missing required SMART scope
403MODULE_NOT_LICENSEDTenant lacks ehr.portal entitlement
403ACCOUNT_NOT_ACTIVEAccount suspended or closed
403MFA_REQUIREDAction requires elevated MFA assurance level
403PROXY_SCOPE_EXCEEDEDProxy session lacks access to this resource type
404RESOURCE_NOT_FOUNDUpstream resource not found or not released
409SLOT_UNAVAILABLERequested appointment slot no longer available
429RATE_LIMITEDToo many requests from this account
503UPSTREAM_UNAVAILABLEAn upstream service is temporarily unavailable