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
- 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 asredirect_uribelow) and request the scopes you need, e.g.openid fhirUser offline_access patient/*.read. Epic requireshttpsredirect_uris for production use -- plainhttpis only accepted for sandbox/dev testing.SmartAuthFlowhandles this automatically: whenredirect_uriishttps, the local callback listener wraps itself in a freshly generated self-signed cert (seelocalcert.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 inredirect_uriexactly -- Epic's match is exact, and a scheme-only mismatch produces a generic, hard-to-diagnose "OAuth2 Error" page with no useful detail. - Find your hospital's FHIR base URL. Either browse
open.epic.com/Endpoints/R4 yourself,
or use
discovery.search_epic_endpoints("Your Hospital Name"). - 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.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| open_epic-0.1.1.tar.gz | 56.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| open_epic-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 98.8 kB
Release files / open_epic-0.1.1.tar.gz
| Download URL | open_epic-0.1.1.tar.gz |
|---|---|
| Size | 56.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
63712c735f3383d12198eb3ed9c4b23d7b62b7c1456555aa38a2abf5b8313cd1
|
|
BLAKE2b-256 checksum How to use checksums |
a92f8a44dd187e0618711393c334c6646f99abcebef6e9b90ca9bc293135685a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 30, 2026.
Transparency logRelease files / open_epic-0.1.1-py3-none-any.whl
| Download URL | open_epic-0.1.1-py3-none-any.whl |
|---|---|
| Size | 42.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2c94f3810b4833c2ce376d3deee5251c71c76c203bf7fc08dc3db7ad5139aadd
|
|
BLAKE2b-256 checksum How to use checksums |
fc5a8a52a69f026f52058b027852431f4074ba9f408e707b321134905275d04a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Jul 30, 2026.
Transparency log