All articles
19 min read2026-03-12

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 FHIRFHIR R4SMART on FHIREHR IntegrationHealthcare InteroperabilityEpic MyChart

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

  1. Go to fhir.epic.com and create a developer account.
  2. Create a new application in the App Orchard developer portal.
  3. Configure your application's OAuth 2.0 redirect URIs, FHIR scopes, and launch type (standalone, EHR launch, or backend services).
  4. Epic immediately provisions sandbox credentials — Client ID and (for confidential clients) Client Secret.
  5. 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:

  1. App Orchard review — Epic reviews your application for security, data minimisation, and FHIR conformance. This typically takes 4-8 weeks for the initial review.
  2. 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.
  3. 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 clients

The token response includes:

  • access_token — use as Bearer token for FHIR API calls
  • patient — 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 IDs

The 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 results

Pagination 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.