Skip to main content

open-epic

A Python client for reading your own health data out of Epic MyChart, built on Epic's official patient-access FHIR API (SMART on FHIR / OAuth2) -- not screen-scraping. You log in on your hospital's real MyChart page; the app never sees your password, only a scoped access token.

Installing

pip install open-epic

Why FHIR instead of scraping MyChart directly

Under the 21st Century Cures Act, every Epic-powered health system exposes a patient-facing FHIR R4 API. It's the sanctioned way to build exactly this kind of tool: stable, structured JSON, and it doesn't violate MyChart's terms of use the way automating the web UI would.

No file I/O, anywhere in this library

This is a deliberate design constraint, not just an implementation detail: open_epic never reads or writes config, credentials, or health data to disk on its own. OrgConfig and TokenSet are plain dataclasses you construct however you want (hardcoded, env vars, your own file/secrets- manager read); FHIRClient/AsyncFHIRClient take them directly and return typed FHIR objects in memory. If a token gets refreshed mid-session, read the new one back via client.credentials -- nothing gets written anywhere automatically. Persisting anything across runs -- org config, credentials, downloaded data -- is entirely up to your own script; see examples/quickstart.py for the minimal pattern (a creds.json the script reads/writes with plain json, not the library) and examples/download_all.py for a fuller one (dumping every resource category to ./data/).

Read-only by design

open_epic only ever does GET/search -- it never calls a Create, Update, or other write/interactive Epic API (booking or cancelling appointments, sending MyChart messages, submitting forms, etc.), even though Epic exposes some of those for patient apps. This is deliberate: there's no code path in this library that can modify your actual medical record or MyChart account, which also keeps it clear of Epic's stricter review/approval requirements for write-capable apps.

1. Register a client ID for your hospital

  1. Create a free account at open.epic.com and register a new app (SMART on FHIR, patient-facing / standalone launch). Add a redirect URI of https://127.0.0.1:8765/callback (or whatever you pass as redirect_uri below) and request the scopes you need, e.g. openid fhirUser offline_access patient/*.read. Epic requires https redirect_uris for production use -- plain http is only accepted for sandbox/dev testing. SmartAuthFlow handles this automatically: when redirect_uri is https, the local callback listener wraps itself in a freshly generated self-signed cert (see localcert.py, deleted again the moment login finishes), and the browser will show a one-time "not private" warning on the redirect -- click through it ("Advanced" -> "Proceed"), that's expected (and happens on every login, since nothing about the cert is cached between runs). If the redirect URI field splits scheme and host into separate controls, double check the scheme dropdown matches whatever you configure in redirect_uri exactly -- Epic's match is exact, and a scheme-only mismatch produces a generic, hard-to-diagnose "OAuth2 Error" page with no useful detail.
  2. Find your hospital's FHIR base URL. Either browse open.epic.com/Endpoints/R4 yourself, or use discovery.search_epic_endpoints("Your Hospital Name").
  3. Ask your hospital's MyChart/Epic support to activate your client ID for patient use if it isn't already enabled for non-listed apps -- this step varies by health system.

Each health system you want to connect to needs its own registration -- build one OrgConfig per hospital.

2. Log in

from open_epic import OrgConfig, SmartAuthFlow

org = OrgConfig(
    name="my-hospital",
    fhir_base_url="https://fhir.myhospital.org/api/FHIR/R4",
    client_id="your-client-id-from-open.epic.com",
)

tokens = SmartAuthFlow(org).login()  # opens your browser, you log into MyChart

login() opens the hospital's real MyChart login page, waits for you to approve access, catches the redirect on a local server, and returns a TokenSet -- access token, refresh token (if the org grants one), expiry, patient ID. That's it; nothing gets written to disk. If you want to reuse it later without logging in again, save it yourself:

import json
from pathlib import Path

Path("creds.json").write_text(json.dumps(tokens.as_dict(), indent=2))

# ...later, a different run:
from open_epic import TokenSet
tokens = TokenSet.from_dict(json.loads(Path("creds.json").read_text()))

Note some Epic orgs cap the access token's lifetime (e.g. a fixed 1 hour) with no refresh token issued at all -- MyChart's own consent screen ("How long will the app have access to my information?") will tell you what your org actually grants. If there's no refresh token, login() is the only way to get a new one; the library can't work around an org's own policy.

2b. Or skip the object plumbing: open_epic.init(...)

A thin module-level session on top of everything above, for scripts that just want "authenticate once, then go":

import open_epic
from open_epic import OrgConfig

org = OrgConfig(name="my-hospital", fhir_base_url="...", client_id="...")

open_epic.init(org)  # opens your browser, logs in
# or, if you already have tokens from a previous run (see above):
open_epic.init(org, credentials=tokens)  # no browser, reuses them

if open_epic.is_initted:
    client = open_epic.get_client()
    print(client.patient.me().name[0].family)
    # save for next time, same pattern as above:
    creds_json = client.credentials.as_dict()

Pass force_login=True to always do a fresh interactive login even if credentials= was given.

OrgConfig.listen_host/listen_port control what the local redirect- catcher actually binds to, separate from redirect_uri (which is what Epic and the browser see). Leave both unset for the common case where they match; set them explicitly for port-forwarding/reverse-proxy/remote-dev setups where the externally-visible address in redirect_uri isn't something you can bind() to directly (e.g. binding 0.0.0.0 locally while redirect_uri points at a dynamic-DNS hostname or forwarded public port).

Token validity, expiry, and introspection

TokenSet.is_expired is a local check against the expires_at timestamp recorded at login/refresh -- cheap, no network call, and what the client itself uses before every request to decide whether to silently refresh:

if tokens.is_expired:
    tokens = SmartAuthFlow(org).refresh(tokens)

For a live, authoritative answer -- e.g. the patient revoked the app early from MyChart's "Connected Apps" settings, which a local expiry check can't see -- use RFC 7662 introspection instead:

result = client.introspect()  # defaults to the session's own access token
# or: SmartAuthFlow(org).introspect(some_token)

print(result.active)          # bool -- authoritative right now
print(result.scope, result.exp, result.client_id)
print(result.raw)             # anything the org returned beyond the known fields

Raises AuthenticationError if the org doesn't advertise an introspection_endpoint in its SMART configuration (optional, not every org exposes it) or if the request itself fails. active: False in a successful response isn't an error -- it's the org correctly telling you the token no longer works.

If you requested the openid scope, TokenSet.id_token holds the signed JWT Epic returns alongside the access token -- typically sub, fhirUser (a reference to your Patient/RelatedPerson/Practitioner resource), iss, iat, exp. Read its claims without verifying the signature (safe for a token you just received directly from the org's token endpoint over TLS; don't extend that trust to an id_token that arrived some other way):

from open_epic import decode_id_token

claims = decode_id_token(tokens.id_token)
print(claims["fhirUser"])  # e.g. "Patient/eXYZ123"

3. Pull data -- scripts (sync)

from open_epic import FHIRClient

with FHIRClient(org, tokens) as client:
    me = client.patient.me()
    print(me.name[0].family)

    for appt in client.appointments.list():
        print(appt.start, appt.status)

    for result in client.labs.results():
        print(result.code.text, result.valueQuantity)

    for med in client.medications.orders(status="active"):
        print(med.medicationCodeableConcept.text)

    for doc in client.documents.list():
        content = client.documents.content_of(doc)  # raw bytes (e.g. PDF)

4. Pull data -- async (e.g. inside FastAPI)

from open_epic import AsyncFHIRClient

async def get_labs(org, tokens):
    async with AsyncFHIRClient(org, tokens) as client:
        return [r async for r in client.labs.results()]

The sync and async clients expose the identical shape (.patient, .appointments, .medications, .labs, .clinical, .documents, .messages) -- pick whichever fits where you're calling it from.

What's available

Every FHIR resource type Epic exposes to patient-facing apps -- cross-checked against the live, full API catalog at fhir.epic.com/Specifications (not just a manual read of the Incoming APIs picker) -- is reachable here: via a dedicated accessor, client.resolve(), or the generic client.search() escape hatch below. This library is read-only by design (see below) -- Epic also publishes write operations (e.g. AllergyIntolerance.Create, Observation.Create for patient-entered vitals, Appointment.$book, QuestionnaireResponse.Create) that intentionally aren't implemented here. Not every org enables every API for patient use regardless of what Epic's catalog lists -- worth spot-checking against your own org if you depend on something specific.

Accessor FHIR resources
client.patient Patient (your own demographics)
client.appointments Appointment (scheduled visits), Slot/Schedule via .slots()/.schedules() (provider availability, not patient-scoped)
client.encounters Encounter (visits that actually happened)
client.medications MedicationRequest, MedicationStatement, MedicationDispense (refill history), MedicationAdministration (doses given via a line/drain/airway, via .administrations()), Medication via .definitions() (drug catalog entries)
client.labs Observation -- .results() (labs), .vitals(), .social_history(), .assessments(), .by_category(...) for anything else (Epic has several more Observation categories than dedicated methods cover; see the module docstring)
client.diagnostics DiagnosticReport, Specimen, Media (imaging-adjacent, "Study" category)
client.clinical Condition, AllergyIntolerance, Immunization, ImmunizationRecommendation (vaccine forecast, via .immunization_recommendations()), Procedure, Device, FamilyMemberHistory, Consent, RelatedPerson, AdverseEvent, BodyStructure, DeviceUseStatement, Flag, Substance
client.care CarePlan, CareTeam, Goal, EpisodeOfCare
client.documents DocumentReference (+ binary content download)
client.messages Communication (availability varies by health system)
client.coverage Coverage (insurance)
client.billing ExplanationOfBenefit (claims), Account (billing account status), Contract (reimbursement terms, via .contracts())
client.forms QuestionnaireResponse (MyChart intake/screening forms), Questionnaire (the form templates themselves, via .definitions())
client.orders ServiceRequest, Task, NutritionOrder, DeviceRequest, RequestGroup
client.provenance Provenance (who/when a record was entered)
client.directory Practitioner, PractitionerRole, Organization, Location -- searched directly (e.g. by name), not by reference
client.research ResearchStudy (clinical trials/studies you're enrolled in), ResearchSubject (your enrollment status/arm, via .subjects())
client.lists List (Epic's curated summary lists -- active problem list, current med list, etc. -- distinct from an unfiltered resource search; see the module docstring on why there's no per-list-type convenience method)

client.resolve("Practitioner/abc123") fetches a looked-up-by-id resource (Practitioner, PractitionerRole, Organization, Location, Medication) from a reference found on another resource. Use this when you already have the reference (e.g. an Encounter's participant); use client.directory/client.medications.definitions() instead when you want to search independently (e.g. "find a practitioner named Smith").

client.everything() calls the Patient/$everything operation, and client.summary() calls Patient/$summary (the International Patient Summary -- a curated clinical document). Both return most/some of the resource types above in one paginated Bundle instead of calling each accessor separately, on orgs that support them. Neither is universally enabled -- treat them as shortcuts to try, not something to depend on.

client.expand_valueset(url) calls ValueSet/$expand -- a terminology operation (not patient data) for expanding a ValueSet's canonical URL into its full list of codes, useful for building pick-lists or validating a code against what the org actually supports.

Not every Epic org exposes every resource type to patients -- if a request comes back empty, 403s, or 404s, that's the hospital's scope configuration, not a bug in the client. Notably, raw imaging study/DICOM metadata (ImagingStudy) generally isn't exposed to patient apps at all; imaging results reach patients through client.diagnostics.reports()/.media() or as PDFs via client.documents.

Every method returns a lazily-paginated iterator (or async iterator) of typed fhir.resources model objects; results that don't validate against the model fall back to plain dicts so an unexpected field never hard-fails a request.

For anything not covered by the accessors above, drop to the generic API:

client.search("Encounter", patient=client.patient_id, status="finished")
client.get_resource("Encounter", "some-id")

Formatting helpers

FHIR's structured types (HumanName, CodeableConcept, Quantity, FHIR date strings, Address, ContactPoint) are verbose to turn into plain display strings. These are optional, dependency-free pure functions for that -- every resource wrapper already returns full typed objects regardless of whether you use them:

from open_epic import (
    format_human_name, codeable_concept_text, format_quantity,
    parse_fhir_date, format_address, format_contact_point,
)

me = client.patient.me()
print(format_human_name(me.name))            # "Jane A Smith"
print(format_address(me.address[0]))          # "123 Main St, Cincinnati, OH 45202"
print(format_contact_point(me.telecom, system="phone"))

for obs in client.labs.results():
    print(codeable_concept_text(obs.code), format_quantity(obs.valueQuantity))
    # "Body temperature 98.6 degF"

for cond in client.clinical.conditions():
    onset = parse_fhir_date(cond.onsetDateTime)  # date or datetime, per precision

Development

pip install -e ".[dev]"
pytest

Publishing a release

rm -rf dist build
python -m build           # produces dist/*.whl and dist/*.tar.gz
twine check dist/*        # validate metadata before uploading
twine upload dist/*       # or: upload to https://test.pypi.org/legacy/ first

Bump version in pyproject.toml before building. CI (.github/workflows/publish.yml) publishes automatically on a published GitHub Release via PyPI's trusted publishing (no stored API token) -- configure the pypi environment as a trusted publisher on the PyPI project settings page first.

Metadata

Release files for open-epic 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for open-epic 0.1.0
File Size Uploaded
open_epic-0.1.0.tar.gz 54.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for open-epic 0.1.0
File Interpreter ABI Platform
open_epic-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 97.0 kB

Release files / open_epic-0.1.0.tar.gz

Download URL open_epic-0.1.0.tar.gz
Size 54.6 kB
Tags Source
SHA-256 checksum
How to use checksums
357032abcf14a1abcd0b6ff96fcea61dd79081f5a563bf42a6197c511d025e64
BLAKE2b-256 checksum
How to use checksums
e62cc8742ed600336b055feac5f5cce89dabf0c06fdd390fc8c3869bcabd95de
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.1

Release files / open_epic-0.1.0-py3-none-any.whl

Download URL open_epic-0.1.0-py3-none-any.whl
Size 42.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
690d5016baadefb34b7b873b49ba6963e781829501b53a02b224494a974b37a6
BLAKE2b-256 checksum
How to use checksums
e04301c9c7ee6083fbe35895bafba5bbfb73a36d6f4db6d9c121ba0cd4735347
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.1

Release history Release notifications | RSS feed

0.1.1

2 release files

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page