Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Patient Access Integration

JHE can pull a patient’s clinical data from any SMART on FHIR patient-access API (Epic, Cerner/Oracle Health, Athena, ...) using the SMART App Launch standalone patient launch. Epic’s sandbox is the seeded example, but nothing in the client is Epic-specific: a patient searches for their hospital, picks it, is sent to that hospital’s login, and their records are converted and stored in JHE.

This is the single onboarding doc for the Patient Access client: hospital branding, setup, configuration, and an end-to-end test. The flow is vendor-agnostic - only the FHIR endpoint (iss) and brand differ per provider.

WhatWhere
Page views + brands search + identifier proxycore/views/patient_access.py
Hospital-brand modelscore/models/ehr_brand.py
Brands importer + sample bundlecore/management/commands/import_ehr_brands.py
Connect / callback pagescore/templates/clients/patient-access/
Browser SMART flow (vanilla JS)core/static/clients/patient-access/js/client-patient-access.js
Client + config seedcore/management/commands/seed.py
Teststests/backend/test_patient_access*.py, tests/frontend/tests/patient-access-*.test.js

Architecture

Unlike the OW integration (a server-side poller), the Patient Access flow runs client-side in the browser: a vanilla-JS page hosted on JHE redeems the invitation for a JHE token, lets the patient pick their hospital, runs the EHR’s PKCE OAuth, pulls the patient’s FHIR data, and writes it back into JHE. The backend serves the pages, holds the EHR config in JheClient.aux_data, searches the hospital-brand tables, converts each pulled R4 resource to R5, and stores it.

Patient browser ─▶ /clients/patient-access/?code=<invitation>   (connect.html)
   ├─ POST /api/v1/invitation/<token>          ─▶ redeem invitation
   ├─ POST /o/token/                           ─▶ JHE access token (PKCE)
   ├─ GET  /api/v1/patient-access/brands?q=    ─▶ hospital search (pick one -> its iss)
   └─ FHIR.oauth2.authorize(iss, clientId, scope, redirectUri)
          │
     EHR login ◀───── login + consent ─────────┘
          │
          ▼
   /clients/patient-access/callback   (callback.html)
       │  FHIR.oauth2.ready()  ─▶ EHR access token + patient id
       ├─ POST /api/v1/patient-access/identifier  ─▶ PatientIdentifier (EHR patient id)
       ├─ POST /api/v1/fhir_sources               ─▶ FhirSource id
       ├─ GET  <EHR>/{Patient,Condition,...}      ─▶ pull each USCDI type (R4)
       └─ POST /fhir-import/R4/{type}             ─▶ convert R4->R5, store as FhirAuxResource
              (header X-JHE-FHIR-Source-ID)          "Conditions: N", "Labs: N", ...

The EHR patient id is stored as a PatientIdentifier (system = the EHR iss, value = the EHR patient id). The client pulls the demo USCDI set (Patient demographics, Condition, MedicationRequest, AllergyIntolerance, lab Observations); each type is fetched and written independently so one failure does not abort the rest. Because the EHR serves R4 and JHE stores R5, each resource is POSTed to the /fhir-import/R4/ endpoint, which converts it via the HL7 cross-version maps and then runs the normal create - landing non-OMH resources in FhirAuxResource, linked to a FhirSource the patient registers on the fly.

Hospital branding / selection (SMART App Launch 2.2)

A JHE invitation doesn’t say which hospital the patient uses, so the connect page shows a search box first. This implements SMART 2.2 “user-access brands”.

Seeding + refreshing the brand list

manage.py seed loads a small curated sample (core/data/ehr_brands.sample.json) so the picker has data out of the box. For a full list, download a vendor’s user-access Brands Bundle and import it:

# Epic publishes ~94k organizations / ~800 endpoints:
curl -sSL -o epic_brands.json https://open.epic.com/MyApps/Endpoints
docker compose exec web python manage.py import_ehr_brands --file epic_brands.json

The importer is idempotent (brands keyed on fhir_base_url); re-run to refresh.

The full Epic file does not import yet. Epic’s bundle links its organizations to endpoints with urn:uuid references, which the importer does not resolve, so the command above completes with few or no locations. Use the shipped sample until that is fixed - see Out of scope.

Extending to other EHR vendors

The importer parses any SMART user-access Brands Bundle, so adding Cerner/Athena/etc. is importing that vendor’s bundle - no code change for search or selection. The one future code change for real multi-vendor auth is a per-vendor client_id (today the single EHR client_id lives in the Patient Access client’s aux_data). If CMS ships its planned national brands directory, point the importer at that instead.

Configuration

EHR config (JheClient.aux_data)

The seeded Patient Access client carries the EHR config in its aux_data JSON blob. The connect/callback views inject it into the page; fhir-client.js discovers the authorize/token endpoints from {iss}/.well-known/smart-configuration.

{
  "client_id": "<EHR non-production client id>",
  "scopes": "openid profile launch/patient patient/Patient.read patient/Observation.read patient/Condition.read patient/MedicationRequest.read patient/AllergyIntolerance.read"
}

There is no iss in aux_data. The hospital the patient picks supplies it (EhrBrand.fhir_base_url) at authorize time, and the callback reads it back from the authorized server. Only client_id and scopes are configured.

Register the EHR app (one-time, Epic example)

Each deploying institution registers its own app at fhir.epic.com; the redirect URI and APIs live on the EHR app, not in JHE.

  1. Sign in at fhir.epic.com, Build Apps -> Create. Audience Patients, SMART on FHIR standalone launch, public client (PKCE, no secret).

  2. Incoming APIs (R4): Patient.Read, Observation.Search/Read, Condition.Search/Read, MedicationRequest.Search/Read, AllergyIntolerance.Search/Read.

  3. Redirect URIs: add, byte-for-byte, your callback URL per environment:

    • Local: http://localhost:8001/clients/patient-access/callback

    • Fly: https://jhe.fly.dev/clients/patient-access/callback

  4. Save; copy the issued non-production client id into the JHE config below.

Epic can take ~1-12h to sync a new redirect URI. A redirect_uri mismatch right after editing usually just means it hasn’t propagated yet.

Point JHE at your EHR app

docker compose exec web python manage.py shell -c "
from oauth2_provider.models import get_application_model as G
c = G().objects.get(name='Patient Access').jhe_client
c.aux_data['client_id'] = '<your EHR non-production client id>'
c.save()
"

The EHR’s FHIR base URL is not configured here. It comes from the EhrBrand row the patient picks, so import that vendor’s brands bundle instead (see Seeding + refreshing the brand list).

Local setup (Docker)

The default Compose stack serves :8001 with SITE_URL=http://localhost:8001. The seed also creates the Patient Access API DataSource (every FhirSource needs one), seeds the sample hospital brands, and wires the Patient Access client to the example patient Peter (ll_patient_peter@example.com), so the flow is testable out of the box.

cd jupyterhealth-exchange
docker compose up --build -d
# wait for the web container's migrate to finish, then seed a clean DB:
docker compose exec web python manage.py seed --flush-db

Verify:

CheckExpected
GET http://localhost:8001/clients/patient-access/200, connect page with a hospital search box
GET http://localhost:8001/static/clients/patient-access/js/client-patient-access.js200
Page source contains PATIENT_ACCESS_CONFIG with clientId and scope (no iss)yes

End-to-end test

StepActionExpected
1. Unit testspython -m pytest tests/backend/test_patient_access.py tests/backend/test_patient_access_brands.pypass
2. PractitionerLog in at http://localhost:8001/ as manager_mary@example.com / Jhe1234!, open patient Peter, Generate Invitation Link for the Patient Access clientLink at .../clients/patient-access/?code=...
3. ConnectOpen the link in a new tab“Redeeming invitation...” → “Choose your hospital” + search box
4. Pick hospitalSearch (e.g. mount, san jose) and click a resultRedirect to that hospital’s login
5. EHR loginLog in as an Epic sandbox test patient (e.g. Camila Lopez fhircamila / epicepic1)Consent, then return to /clients/patient-access/callback
6. Import(automatic on the callback page)EHR patient id, “Stored ... in JHE”, per-type counts (Demographics, Conditions, Medications, Allergies, Labs)
7. DataJHE Admin UI → FHIR Resources, or query FhirAuxResource per typeImported rows for Peter, linked to his FhirSource

Epic sandbox test patients: https://fhir.epic.com/Documentation?docId=testpatients.

Troubleshooting

SymptomLikely cause
redirect_uri mismatch on the EHR callbackServed origin/port ≠ the URI registered on the EHR app, or < ~1-12h since the edit. Use :8001.
“no invitation code in URL”Opened /clients/patient-access/ directly; start from a real invitation link.
Picker never appearsInvitation not redeemed (no JHE token). Check the invitation link/host.
“failed to exchange invitation for JHE token”The client’s Application.redirect_uris isn’t the JHE /auth/callback. Re-seed.
A few Conditions rejected with clinicalStatus field requiredThe EHR’s data for that patient omits clinicalStatus, which R5 requires - correctly rejected, not a bug. The import returns an OperationOutcome per record explaining why.
Over-long resource id rejectedEpic (“Unconstrained FHIR IDs”) emits ids over the FHIR 64-char limit; the client relocates an over-long id into an identifier before writing.
invalid scopeA patient/<Resource>.read scope is requested but that API isn’t enabled on the EHR app.

Out of scope (follow-up)