Epic FHIR API Integration Guide: A Developer's Complete Reference for 2026
The practical guide to integrating with Epic's FHIR R4 API — App Orchard registration, SMART on FHIR authentication, supported resource types, bulk data export, rate limits, and the vendor-specific quirks you will not find in the official docs.
Epic is the largest EHR vendor in the United States — used by over 350 health systems covering more than 300 million patient records. If you are building a healthcare application in the US, you almost certainly need to connect to Epic. And Epic's primary integration mechanism for modern applications is its FHIR R4 API.
This guide covers everything you need to actually build a working Epic FHIR integration — from App Orchard registration through SMART on FHIR authentication, supported resources, bulk data, rate limits, and the vendor-specific behaviour you will not find in the official documentation. It is written for software engineers, not for Epic analysts.
Epic's FHIR Landscape
Epic exposes multiple FHIR APIs depending on the use case:
- MyChart FHIR API — patient-facing API enabling patients to access their own data via SMART on FHIR standalone launch. Required by ONC for patient access.
- Clinical FHIR API — provider-facing API for clinical applications launching within Epic's workflow via SMART on FHIR EHR launch.
- Interconnect FHIR API — backend service API for system-to-system integration without user interaction, using SMART Backend Services Authentication.
- Bulk FHIR ($export) — asynchronous bulk data export for population health, quality reporting, and analytics.
All of these use FHIR R4 and the US Core Implementation Guide as their conformance baseline. The differences are in authentication patterns and the FHIR scopes they support.
Step 1: Epic App Orchard Registration
Before you can access Epic's FHIR API — even in the sandbox — you need an App Orchard account. This is a non-negotiable step that catches many developers off-guard.
Getting Sandbox Access
- Go to fhir.epic.com and create a developer account.
- Create a new application in the App Orchard developer portal.
- Configure your application's OAuth 2.0 redirect URIs, FHIR scopes, and launch type (standalone, EHR launch, or backend services).
- Epic immediately provisions sandbox credentials — Client ID and (for confidential clients) Client Secret.
- Test against Epic's public sandbox environment with synthetic patient data.
Sandbox access is fast. Production access is the hard part.
Getting Production Access
Production access to a specific Epic customer's environment requires:
- App Orchard review — Epic reviews your application for security, data minimisation, and FHIR conformance. This typically takes 4-8 weeks for the initial review.
- Health system authorisation — the specific health system using Epic must authorise your application for access to their environment. This is entirely their decision and timeline.
- Mutual agreement — Epic's API access agreement and any health system-specific data use agreements must be executed.
Plan for a 2-4 month timeline from starting the App Orchard process to having production access to your first Epic customer environment. Start this process early.
Application Types
Epic distinguishes between:
- Vendor applications — third-party applications built by companies other than the health system. These go through App Orchard review.
- Home-grown applications — applications built by the health system itself, often deployed as Community apps, with a streamlined approval process.
If you are building for a specific health system that will self-host your application, the Community (home-grown) path is significantly faster than the standard vendor review.
Step 2: SMART on FHIR Authentication
Epic uses SMART on FHIR for all OAuth 2.0 authentication flows. The flow you implement depends on your launch type.
Standalone Launch (Patient-Facing Applications)
For applications where a patient authenticates directly (MyChart integration, patient-directed apps):
# Step 1: Get Epic's FHIR metadata (discovery)
GET https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4/.well-known/smart-configuration
# Response includes:
# authorization_endpoint, token_endpoint, scopes_supported, etc.
# Step 2: Redirect patient to Epic's authorisation endpoint
GET https://fhir.epic.com/interconnect-fhir-oauth/oauth2/authorize?
response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://yourapp.com/callback
&scope=openid fhirUser patient/Patient.read patient/Observation.read
&state=RANDOM_STATE_VALUE
&aud=https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4
# Step 3: Exchange authorisation code for access token
POST https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=AUTH_CODE
&redirect_uri=https://yourapp.com/callback
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET # Omit for public clientsThe token response includes:
access_token— use as Bearer token for FHIR API callspatient— the FHIR Patient ID of the authenticated patient (for standalone launch)expires_in— token lifetime in seconds (Epic tokens typically expire in 3600 seconds)refresh_token— for obtaining new access tokens without user re-authentication
EHR Launch (Clinical Applications)
For applications that launch within Epic's clinical workflow (appearing in Epic's app launcher, embedded in patient charts):
# Step 1: Epic calls your launch endpoint with launch and iss parameters
# GET https://yourapp.com/launch?launch=LAUNCH_TOKEN&iss=EPIC_FHIR_BASE_URL
# Step 2: Use the launch token in the authorisation request
auth_url = f"{authorization_endpoint}?" + urllib.parse.urlencode({
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"scope": "openid fhirUser launch patient/Patient.read user/Observation.read",
"state": generate_state(),
"aud": epic_fhir_base_url,
"launch": launch_token # Required for EHR launch
})
# Step 3: Token exchange — same as standalone but includes launch context
# Token response includes patient, encounter, practitioner context IDsThe EHR launch token encodes the clinical context — which patient's chart is open, which encounter the clinician is in, who the authenticated user is. Your application uses these IDs to retrieve context-appropriate FHIR data.
Backend Services (System-to-System)
For data pipelines, analytics, and applications that do not involve real-time user interaction:
import jwt
import time
import uuid
import httpx
# Epic requires JWT signed with your private key (RSA or EC)
def create_client_assertion(client_id: str, token_endpoint: str, private_key: str) -> str:
payload = {
"iss": client_id,
"sub": client_id,
"aud": token_endpoint,
"jti": str(uuid.uuid4()),
"exp": int(time.time()) + 300, # 5 minute expiry
}
return jwt.encode(payload, private_key, algorithm="RS384")
async def get_backend_token(client_id: str, token_endpoint: str, private_key: str) -> str:
assertion = create_client_assertion(client_id, token_endpoint, private_key)
response = await httpx.post(token_endpoint, data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
})
return response.json()["access_token"]Backend service authentication requires registering your public key with Epic — in the App Orchard portal, you upload your public JWK (JSON Web Key). Epic uses this to verify your JWT assertion without exchanging a client secret.
Step 3: Supported FHIR Resources
Epic does not support all FHIR R4 resources. The supported set varies by:
- Epic version (which build the health system is on)
- Configuration at the health system level
- Authorised scopes for your application
The commonly-supported resources as of 2026:
| Resource | Standalone | EHR Launch | Backend | |----------|-----------|------------|---------| | Patient | ✅ | ✅ | ✅ | | Encounter | ✅ | ✅ | ✅ | | Condition | ✅ | ✅ | ✅ | | Observation | ✅ | ✅ | ✅ | | MedicationRequest | ✅ | ✅ | ✅ | | AllergyIntolerance | ✅ | ✅ | ✅ | | Immunization | ✅ | ✅ | ✅ | | DiagnosticReport | ✅ | ✅ | ✅ | | DocumentReference | Limited | ✅ | ✅ | | Procedure | ✅ | ✅ | ✅ | | CarePlan | ❌ | ✅ | ✅ | | Appointment | Limited | ✅ | Limited |
Always validate the actual supported resources against the specific Epic environment you are integrating with — use GET /metadata to retrieve the CapabilityStatement.
Step 4: Making FHIR API Calls
With a valid access token, Epic FHIR API calls follow standard FHIR REST conventions:
import httpx
class EpicFHIRClient:
def __init__(self, base_url: str, access_token: str):
self.base_url = base_url.rstrip("/")
self.headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/fhir+json",
"Content-Type": "application/fhir+json"
}
async def get_patient(self, patient_id: str) -> dict:
response = await httpx.get(
f"{self.base_url}/Patient/{patient_id}",
headers=self.headers
)
response.raise_for_status()
return response.json()
async def search_observations(self, patient_id: str, loinc_code: str,
date_from: str = None) -> list:
params = {
"patient": patient_id,
"code": f"http://loinc.org|{loinc_code}",
}
if date_from:
params["date"] = f"ge{date_from}"
results = []
url = f"{self.base_url}/Observation"
while url:
response = await httpx.get(url, headers=self.headers, params=params)
response.raise_for_status()
bundle = response.json()
# Extract resources from Bundle.entry
for entry in bundle.get("entry", []):
results.append(entry["resource"])
# Follow pagination links
url = None
params = None # Params are included in next link
for link in bundle.get("link", []):
if link["relation"] == "next":
url = link["url"]
break
return resultsPagination Is Mandatory
Epic returns FHIR search results in pages — typically 20-100 resources per page. Every search result handler must follow the next link in the Bundle until there are no more pages. Missing this step will silently truncate your results.
The link array in a FHIR Bundle response:
{
"resourceType": "Bundle",
"link": [
{"relation": "self", "url": "https://fhir.epic.com/.../Observation?..."},
{"relation": "next", "url": "https://fhir.epic.com/.../Observation?...&_page=2"}
],
"entry": [...]
}Step 5: Epic-Specific Quirks
Extension-Heavy Resources
Epic adds proprietary extensions to many resources. These extensions contain Epic-specific data not available in standard FHIR. For example, Epic's Patient resource may include:
{
"resourceType": "Patient",
"extension": [
{
"url": "http://open.epic.com/FHIR/StructureDefinition/extension/patient-merge-unmerge-status",
"valueString": "Active"
}
]
}Extensions prefixed with http://open.epic.com/FHIR/StructureDefinition/ are Epic-specific. Handle them only when you specifically need Epic data that has no standard FHIR equivalent.
Epic-Specific Search Parameters
Epic supports some custom search parameters not defined in the FHIR specification. These are documented in Epic's developer portal but will cause 400 Bad Request errors on non-Epic FHIR servers. Use them when needed but do not include them in code intended to work across multiple FHIR server implementations.
Observation.value Variations
Epic reports lab results using different value representations depending on the result type:
// Numeric result
{"valueQuantity": {"value": 7.2, "unit": "g/dL", "system": "http://unitsofmeasure.org", "code": "g/dL"}}
// Coded result
{"valueCodeableConcept": {"coding": [{"system": "...", "code": "...", "display": "Positive"}]}}
// String result (free text)
{"valueString": "Indeterminate"}Your code must handle all three value types, plus dataAbsentReason for results that exist but have no value. A switch on resourceType is not sufficient — switch on which value[x] field is present.
Rate Limits
Epic enforces rate limits that vary by API tier and health system configuration. The standard rate limit for App Orchard applications is typically 100 requests per minute per access token. For backend service integrations processing large populations, this is a significant constraint.
Design patterns for working within Epic rate limits:
- Use bulk $export rather than per-patient FHIR calls for population data
- Cache frequently-accessed reference data (Practitioner, Organization, Location resources)
- Implement token-bucket rate limiting in your client to stay under limits proactively
- For high-volume needs, discuss enhanced rate limit tiers with Epic during App Orchard review
Step 6: Bulk Data Export ($export)
For population health, quality reporting, and analytics use cases involving large patient populations, Epic's bulk data export is far more efficient than individual FHIR calls.
async def start_bulk_export(base_url: str, token: str,
resource_types: list[str] = None) -> str:
"""Initiate a bulk export job and return the status URL."""
params = {}
if resource_types:
params["_type"] = ",".join(resource_types)
response = await httpx.get(
f"{base_url}/$export",
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/fhir+json",
"Prefer": "respond-async" # Required for bulk export
},
params=params
)
# Epic returns 202 Accepted with Content-Location header
assert response.status_code == 202
return response.headers["Content-Location"]
async def poll_export_status(status_url: str, token: str) -> dict:
"""Poll until export is complete. Returns output file list."""
while True:
response = await httpx.get(
status_url,
headers={"Authorization": f"Bearer {token}"}
)
if response.status_code == 202:
# Still processing — X-Progress header shows status
await asyncio.sleep(30)
continue
elif response.status_code == 200:
return response.json() # Contains output file URLs
else:
raise Exception(f"Export failed: {response.text}")Bulk export jobs can take minutes to hours depending on population size. Design your pipeline to be resumable — store the status URL and resume polling after restarts.
Epic's bulk export produces NDJSON files (newline-delimited JSON), one FHIR resource per line. Each file contains resources of a single type.
Step 7: CDS Hooks (Clinical Decision Support)
CDS Hooks is a standard for invoking external decision support services from within Epic's clinical workflow. When a clinician opens a patient chart, orders a medication, or signs a note, Epic calls your CDS Hooks service with patient context and expects decision support cards in response.
from fastapi import FastAPI, Request
from pydantic import BaseModel
app = FastAPI()
class CDSRequest(BaseModel):
hookInstance: str
hook: str
context: dict
prefetch: dict = None
@app.post("/cds-services/medication-prescribe")
async def medication_prescribe_hook(request: CDSRequest):
patient_id = request.context.get("patientId")
medications = request.context.get("medications", {})
# Analyse prescribed medications against your decision support logic
cards = await analyse_medication_safety(patient_id, medications)
return {
"cards": cards
}A CDS Hooks card returned to Epic:
{
"summary": "Patient has documented penicillin allergy",
"detail": "The prescribed amoxicillin may cross-react with the documented penicillin allergy recorded on 2024-03-01.",
"indicator": "warning",
"source": {"label": "Your App Name", "url": "https://yourapp.com"},
"suggestions": [
{
"label": "Substitute with azithromycin",
"actions": [{"type": "delete", "description": "Remove amoxicillin order"}]
}
]
}Epic supports the following CDS Hooks: patient-view, order-sign, order-select, appointment-book, and encounter-discharge. Registration of CDS Hooks services is done through the App Orchard portal.
Common Epic Integration Mistakes
Not handling Epic environment differences. Every Epic customer is on a different Epic build, with different configurations, different enabled FHIR resources, and different extension support. Do not assume that what works against one Epic customer works against all of them. Test against your target customers' sandbox environments, not just Epic's generic sandbox.
Treating the access token as long-lived. Epic access tokens typically expire in 3,600 seconds (1 hour). Your integration must handle token refresh proactively — before the token expires — rather than reactively after receiving a 401.
Ignoring the Bundle.total vs actual result count discrepancy. Epic sometimes returns a total value in the Bundle that does not match the actual number of resources returned across all pages. Always count resources from entries, not from total.
Requesting broad scopes when narrow scopes suffice. Epic's App Orchard review is sensitive to scope requests. Request the minimum scopes your application genuinely needs. Requesting patient/*.read when you only need patient/Patient.read and patient/Observation.read will slow your App Orchard approval.
Not registering JWKs correctly for backend services. The public key used for backend service JWT assertions must be registered in the App Orchard portal before attempting authentication. Mismatched keys are a common source of 401 Unauthorized errors that are frustrating to debug.
Conclusion
Epic FHIR integration is a significant engineering investment, but it is the gateway to connecting with the majority of US hospital patients. The key to success is understanding Epic's specific behaviour — not just the FHIR R4 specification — and planning for the App Orchard approval timeline from the beginning of your project.
If you need expert guidance or hands-on development support for Epic FHIR integration, I offer EHR integration services with direct Epic API experience.
Muhammad Moid Shams is a Lead Software Engineer specialising in FHIR R4 integration and healthcare interoperability. He has built production EHR integrations across Epic, Athena Health, PointClickCare, and legacy HL7 v2 systems.