EHR Import
ChartChat imports patient health records from external EHR systems using SMART on FHIR (OAuth2 + PKCE). Once connected, the server periodically syncs FHIR resources from the EHR into ChartChat's FHIR store.
Files: packages/server/src/ehr-import/
Supported EHR Providersβ
| Key | EHR | Auth method |
|---|---|---|
epic | Epic MyChart | PKCE (form-encoded token request) |
healow | eClinicalWorks / Healow | PKCE (form-encoded token request) |
meditech | MEDITECH Expanse | Client secret + JSON token request |
In addition to these built-in providers, admins can configure custom Hospital Integrations that use dynamic SMART discovery to connect to any SMART on FHIR endpoint.
OAuth2 / PKCE Flowβ
1. Patient initiates connectionβ
The Member Portal calls one of:
POST /fhir/ehr-import/startβ for built-in EHR providers (epic,healow,meditech)POST /fhir/ehr-import/hospital-startβ for a configuredHospitalIntegration(uses dynamic SMART discovery)
Both endpoints:
- Authenticate the request (patient bearer token required).
- Verify the caller is a
Patient. - Generate a PKCE pair (
codeVerifier+codeChallenge). - Create a short-lived OAuth state record in Redis (keyed by a UUID).
- Return
{ authorizeUrl }β the full EHR authorization URL including all OAuth params.
2. Browser redirects to EHRβ
The Member Portal redirects the browser to authorizeUrl. The patient authenticates with the EHR and grants consent.
3. EHR redirects back to ChartChatβ
The EHR sends the browser to GET /fhir/ehr-import/callback?code=...&state=....
The callback handler:
- Validates the
stateUUID and reads the stored OAuth state from Redis (single-use β consumed immediately). - Exchanges the authorization code for tokens via the EHR's token endpoint.
- Extracts the external patient ID from the token response (or by calling
GET {fhirBaseUrl}/Patient). - Checks for conflicts β blocks if the EHR account is already linked to a different patient.
- Creates or updates a
PatientEHRConnectionresource with encrypted tokens. - Caches the raw token in Redis (
ehr:token:{connectionId}) for fast sync access. - Triggers background FHIR sync (non-blocking).
- Redirects the browser back to the Member Portal (
FRONTEND_URL).
On any error, the browser is redirected to FRONTEND_URL?ehr_error=<message>.
Token Securityβ
Access tokens and refresh tokens are encrypted before being stored in the FHIR database.
File: ehr-import/token-crypto.ts
- Encryption uses
EHR_TOKEN_ENCRYPTION_KEYfrom the server's.env. - Tokens are decrypted in memory only when needed for API calls.
- Redis cache stores the raw token temporarily (TTL = token expiry time) to avoid repeated database reads during sync.
FHIR Syncβ
Synced Resource Typesβ
When an EHR connection is established or refreshed, the server syncs the following FHIR resource types:
| Resource type |
|---|
Condition |
AllergyIntolerance |
MedicationRequest |
Observation |
Appointment |
CarePlan |
Encounter |
Immunization |
DiagnosticReport |
DocumentReference |
Procedure |
Sync Processβ
File: ehr-import/sync-service.ts, smart/utils/sync-patient.ts
- For each active
PatientEHRConnection:- If a refresh token is available, proactively refresh the access token before syncing.
- If the token is expired and there is no refresh token, mark the connection
token_expiredand skip.
- For each resource type, query the EHR's FHIR API using the patient's external ID.
- Upsert each resource into ChartChat's FHIR store using the internal patient reference.
- Resource types are processed in batches of 3 with a 1-second delay between batches to avoid overwhelming the EHR server.
- After sync completes, trigger an embed sync (
triggerEmbedSync) to re-index the patient's records for the AI chat assistant (see Embed Sync below).
Sync Outcome Statesβ
| Outcome | Meaning |
|---|---|
synced | All resource types fetched and stored successfully |
token_expired | Token expired and no refresh token available β patient must reconnect |
error | Network error or unexpected failure |
Redis Sync Stateβ
During and after sync, status is written to Redis:
| Key | Value | TTL |
|---|---|---|
ehr:sync:status:{patientId} | syncing or idle | 5 minutes |
ehr:sync:result:{patientId} | JSON with syncedProviders, expiredProviders, errorProviders | 5 minutes |
ehr:token:{connectionId} | Raw token JSON | Token expiry |
The 5-minute TTL on the status key is a safety net β if the server crashes mid-sync, the status clears automatically so the next sync attempt is not blocked.
Disconnectingβ
POST /fhir/ehr-import/disconnect/:connectionId
- Verifies the caller is the owner of the connection.
- Sets
PatientEHRConnection.status = revoked. - Deletes the Redis token cache entry.
- Writes an audit log event.
Hospital Integrationsβ
Hospital integrations allow admins to configure custom EHR endpoints without code changes. The flow uses dynamic SMART discovery (smart/smart-discovery.ts) to find the authorization_endpoint and token_endpoint at runtime from the hospital's {fhirBaseUrl}/.well-known/smart-configuration.
This allows connecting to any SMART on FHIRβcompliant system regardless of the EHR vendor, as long as it supports the configured vendor's client credentials.
Embed Syncβ
File: ehr-import/embed-sync.ts
After a successful EHR sync (or when a patient's FHIR data is created/updated), the server triggers a re-index of that patient's records in the AI embedding service. This keeps the AI Chat Assistant's context up to date with the latest health data.
Endpoint calledβ
POST {EMBED_API_URL}/api/embed/{patientId}/sync
| Part | Description |
|---|---|
EMBED_API_URL | Base URL of the embedding/AI service. Set via env var. Defaults to https://dev-chatapi.chartchathealth.com if not set. |
patientId | Internal ChartChat Patient resource ID |
This call is fire-and-forget β the server does not wait for a response. Errors are logged but do not fail the sync or the request.
When it is triggeredβ
| Event | Trigger location |
|---|---|
| After EHR OAuth callback (new connection) | ehr-import/routes.ts |
| After EHR background sync completes | ehr-import/sync-service.ts |
Ingestion Loggingβ
File: smart/utils/sync-patient.ts
Every individual FHIR resource received from an EHR during sync is recorded as an EhrIngestionLog resource. This is a custom FHIR resource type defined in packages/fhirtypes/dist/EhrIngestionLog.d.ts.
EhrIngestionLog fieldsβ
| Field | Type | Description |
|---|---|---|
ehr | string | EHR provider name (e.g. epic, healow, meditech) or Endpoint UUID for hospital integrations |
sourceResourceType | string | FHIR resource type received (e.g. Condition, Observation) |
status | string | success or failed |
syncBatch | string | UUID grouping all resources ingested in the same sync run |
receivedAt | string | ISO timestamp when the resource was received |
patient | Reference | Reference to the internal Patient resource |
connection | Reference | Reference to the PatientEHRConnection (optional β present when connection ID is known) |
storedResource | Reference | Reference to the FHIR resource stored in ChartChat (null if ingestion failed) |
payloadHash | string | SHA-256 hash of the raw payload for deduplication |
ehrData.payload | unknown | Raw JSON payload as received from the EHR |
ehrData.hash | string | Hash of the raw payload |
source.resourceId | string | Resource ID as assigned by the EHR |
source.endpoint | string | Full EHR endpoint URL (e.g. https://fhir.epic.org/.../Condition) |
outcome.error.message | string | Error message if ingestion failed |
timing.processedAt | string | ISO timestamp when processing completed |
timing.durationMs | number | Time taken to process this single resource in milliseconds |
When a log is createdβ
One EhrIngestionLog is created per resource per sync run. If syncing a patient with 3 Conditions and 5 Observations, 8 log entries are created for that batch β each with the same syncBatch UUID.
The connection field is populated only when the sync is triggered from a known PatientEHRConnection (i.e. most normal syncs). It is optional to handle edge cases where the connection ID is not available.
Search parameterβ
EhrIngestionLog has a custom search parameter connection (defined in packages/definitions/) that allows querying logs by PatientEHRConnection reference:
GET /fhir/R4/EhrIngestionLog?connection=PatientEHRConnection/{id}
This is what the Admin Portal EHR Ingestion Log page uses to load all logs for a selected connection.
Audit Loggingβ
Every EHR connect and disconnect event is written as an AuditEvent resource via audit/audit-logger.ts. Failed callback attempts are also logged even if the patient ID is not available at the time of failure.