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.
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <jwt> | Yes | Patient JWT from Keycloak patient realm |
X-Tenant-ID | Yes | Resolved from JWT tid claim; must match portal account |
Accept-Language | No | BCP 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
| HTTP | Code | Description |
|---|---|---|
| 400 | INVALID_REQUEST | Malformed request body or invalid params |
| 401 | UNAUTHORIZED | Missing or invalid JWT |
| 403 | INSUFFICIENT_SCOPE | JWT missing required SMART scope |
| 403 | MODULE_NOT_LICENSED | Tenant lacks ehr.portal entitlement |
| 403 | ACCOUNT_NOT_ACTIVE | Account suspended or closed |
| 403 | MFA_REQUIRED | Action requires elevated MFA assurance level |
| 403 | PROXY_SCOPE_EXCEEDED | Proxy session lacks access to this resource type |
| 404 | RESOURCE_NOT_FOUND | Upstream resource not found or not released |
| 409 | SLOT_UNAVAILABLE | Requested appointment slot no longer available |
| 429 | RATE_LIMITED | Too many requests from this account |
| 503 | UPSTREAM_UNAVAILABLE | An upstream service is temporarily unavailable |