Electronic Health Records (EHR) integration is critical for modern healthcare applications. Epic Systems, one of the largest EHR vendors, provides a FHIR (Fast Healthcare Interoperability Resources) API that allows developers to access and exchange patient data securely. This guide will walk you through the fundamentals of Epic FHIR integration, covering the basics of authentication and making your first API requests.
Whether you’re building a patient portal, clinical decision support system, or healthcare analytics platform, this guide will help you understand the core concepts and get started with Epic FHIR integration.
I. Understanding Epic FHIR API
A. What is FHIR?
FHIR (Fast Healthcare Interoperability Resources) is an HL7 standard for exchanging healthcare information electronically. It uses modern web technologies like REST APIs and JSON, making it easier to work with than older standards like HL7 v2.
B. Epic’s FHIR Implementation
Epic implements FHIR R4 (Release 4) and provides two environments:
- Sandbox: For development and testing (https://fhir.epic.com/interconnect-fhir-oauth)
- Production: For live patient data (varies by healthcare organization)
C. Common FHIR Resources
You’ll typically work with these resources:
- Patient: Demographics and contact information
- Observation: Vital signs, lab results, clinical observations
- Condition: Diagnoses and health conditions
- MedicationRequest: Prescriptions and medication orders
- Appointment: Scheduled patient visits
- Encounter: Patient visits and interactions
II. Authentication: OAuth 2.0 Backend Services
Epic uses the OAuth 2.0 Backend Services flow with JWT (JSON Web Tokens) for authentication. This is more secure than traditional API keys.
A. Prerequisites
Before you start, you’ll need:
- Client ID: Obtained from Epic’s registration process
- Private Key: RSA private key (typically 2048-bit or 4096-bit)
- Public Key: Uploaded to Epic during app registration
- Base URL: Your Epic FHIR endpoint
B. Authentication Flow
The authentication process involves:
- Creating a signed JWT
- Exchanging the JWT for an access token
- Using the access token in API requests
- Refreshing tokens before expiration
C. Implementation: Creating the JWT
import jwt
import time
import uuid
from datetime import datetime, timedelta
def create_jwt(client_id, private_key_path, token_url):
"""
Create a signed JWT for Epic authentication.
Args:
client_id: Your Epic client ID
private_key_path: Path to your RSA private key
token_url: Epic's token endpoint
Returns:
Signed JWT string
"""
# Read private key
with open(private_key_path, 'r') as key_file:
private_key = key_file.read()
# JWT claims
now = int(time.time())
claims = {
'iss': client_id, # Issuer: your client ID
'sub': client_id, # Subject: also your client ID
'aud': token_url, # Audience: Epic's token endpoint
'jti': str(uuid.uuid4()), # Unique identifier for this JWT
'exp': now + 300, # Expiration: 5 minutes from now
'iat': now, # Issued at: current time
}
# Sign with RS384 algorithm (required by Epic)
signed_jwt = jwt.encode(claims, private_key, algorithm='RS384')
return signed_jwtD. Implementation: Exchanging JWT for Access Token
import aiohttp
async def get_access_token(client_id, private_key_path, token_url):
"""
Exchange JWT for an access token.
Args:
client_id: Your Epic client ID
private_key_path: Path to your RSA private key
token_url: Epic's token endpoint
Returns:
Access token string
"""
# Create JWT
jwt_token = create_jwt(client_id, private_key_path, token_url)
# Prepare request
data = {
'grant_type': 'client_credentials',
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'client_assertion': jwt_token,
}
# Exchange JWT for access token
async with aiohttp.ClientSession() as session:
async with session.post(token_url, data=data) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f"Authentication failed: {error_text}")
token_data = await response.json()
return token_data['access_token']E. Best Practices for Token Management
- Cache tokens: Access tokens are valid for 1 hour (typically). Cache them to avoid unnecessary authentication requests.
- Refresh proactively: Refresh tokens before they expire (e.g., at 55 minutes).
- Handle 401 errors: If you get a 401 (Unauthorized), invalidate the cached token and get a new one.
from datetime import datetime, timedelta
class TokenCache:
"""Simple token cache with expiration."""
def __init__(self):
self.token = None
self.expires_at = None
def get_token(self):
"""Get cached token if still valid."""
if self.token and self.expires_at > datetime.now():
return self.token
return None
def set_token(self, token, expires_in_seconds=3300):
"""Cache token with expiration (default 55 minutes)."""
self.token = token
self.expires_at = datetime.now() + timedelta(seconds=expires_in_seconds)
def invalidate(self):
"""Invalidate cached token."""
self.token = None
self.expires_at = NonIII. Making Your First FHIR Request
Once authenticated, you can make FHIR requests using the access token.
A. Basic GET Request
async def get_patient(patient_id, access_token, base_url):
"""
Retrieve a patient by ID.
Args:
patient_id: Epic patient ID
access_token: Valid access token
base_url: Epic FHIR base URL
Returns:
Patient resource as dictionary
"""
url = f"{base_url}/Patient/{patient_id}"
headers = {
'Authorization': f'Bearer {access_token}',
'Accept': 'application/fhir+json',
}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
raise Exception(f"Request failed: HTTP {response.status} - {error_text}")B. Search Requests
FHIR supports powerful search capabilities:
async def search_observations(patient_id, category, access_token, base_url):
"""
Search for observations by patient and category.
Args:
patient_id: Epic patient ID
category: Observation category (e.g., 'vital-signs', 'laboratory')
access_token: Valid access token
base_url: Epic FHIR base URL
Returns:
Bundle of observations
"""
url = f"{base_url}/Observation"
params = {
'patient': patient_id,
'category': category,
'_count': 20, # Results per page
'_sort': '-date', # Sort by date descending
}
headers = {
'Authorization': f'Bearer {access_token}',
'Accept': 'application/fhir+json',
}
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=headers, params=params) as response:
if response.status == 200:
return await response.json()
else:
error_text = await response.text()
raise Exception(f"Search failed: {error_text}")C. Common Search Parameters
| Parameter | Description | Example |
|---|---|---|
| _count | Results per page | _count=50 |
| _sort | Sort results | _sort=-date (descending) |
| date | Filter by date | date=ge2024-01-01 (greater than or equal) |
| _include | Include related resources | _include=Observation:patient |
| _revinclude | Include resources that reference this | _revinclude=Observation:subject |

Conclusion
This guide covered the fundamentals of Epic FHIR integration:
- Understanding Epic FHIR API: The basics of FHIR resources and Epic’s implementation
- Authentication: How to implement OAuth 2.0 Backend Services with JWT
- Making FHIR Requests: Performing GET requests and searches with proper parameters
These core concepts provide the foundation for building Epic FHIR integrations. As you develop your application, you’ll want to consider additional patterns like rate limiting, retry logic, connection pooling, and pagination handling for production deployments.
A. Additional Resources
- Epic FHIR Documentation: https://fhir.epic.com/
- FHIR R4 Specification: https://hl7.org/fhir/R4/
- OAuth 2.0 RFC: https://tools.ietf.org/html/rfc6749
- JWT RFC: https://tools.ietf.org/html/rfc7519
B. Getting Started
Epic provides a sandbox environment for testing:
- Sandbox URL: https://fhir.epic.com/interconnect-fhir-oauth
- Test Patients: Epic provides test patient IDs for development
Start with the sandbox, experiment with the basic patterns shown in this guide, and expand your implementation as needed!
Disclaimer:
The Epic FHIR sandbox URL is a base OAuth and FHIR endpoint intended for programmatic access only. Attempting to open this URL directly in a web browser may result in a “Bad Request” or similar error. This is expected behavior.
To interact with this endpoint, you must use an API client (e.g., Postman or application code) and send properly structured OAuth and FHIR requests with the required headers and payloads, as defined in Epic’s FHIR documentation.































