If you build a healthcare application that connects to one EHR, congratulations — you have solved approximately 30% of the market. Epic holds roughly 38% of US hospital beds. Oracle Health (Cerner) covers about 25%. athenahealth dominates ambulatory with 17% of outpatient practices. The remaining market is fragmented across MEDITECH, Allscripts, eClinicalWorks, and dozens of smaller vendors.
Every health system you sell to will ask the same question: "Does it work with our EHR?" The answer needs to be yes for all of them, through one consistent API. That means building a multi-EHR integration layer — an adapter pattern that abstracts vendor-specific differences behind a unified interface your application can rely on.
This guide covers the architecture, the vendor-specific differences you must handle, the adapter pattern implementation in Python, and the operational challenges of running a multi-vendor integration layer in production. This is what we build at Nirmitee for our healthcare clients, and what we have learned from doing it across dozens of deployments.
Why You Need an Abstraction Layer
"They all support FHIR R4" is the claim. Here is the reality:
- Auth models differ: Epic uses SMART on FHIR with granular scopes. Oracle uses both SMART and system-level client credentials with different token behaviors. athenahealth uses API keys alongside OAuth.
- FHIR versions differ: Epic is fully R4. Oracle still has DSTU2 endpoints for some resources. athenahealth has a proprietary API alongside partial FHIR support.
- Data shapes differ: The same Patient resource from Epic and Oracle will have different extensions, different identifier systems, and different coding choices.
- Pagination differs: Epic uses
nextlink-based pagination. Oracle uses offset-based. athenahealth has custom pagination tokens. - Rate limits differ: Each vendor has different throttling rules, different error responses for rate limits, and different backoff expectations.
- Error formats differ: FHIR OperationOutcome structures vary. Some vendors return non-FHIR errors for certain failure modes.
Without an abstraction layer, your application code becomes a mess of if vendor == "epic" conditionals scattered across every module. The adapter pattern centralizes this complexity. For the foundational thinking behind this approach, see our guide on The Mental Model for Healthcare Integrations.
The Architecture: Three Layers
The multi-EHR integration layer has three distinct layers:
- Unified FHIR API — The interface your application uses. Consistent request/response format regardless of the downstream EHR.
- Vendor Adapter Layer — Per-vendor adapters that translate between your unified format and vendor-specific APIs, auth flows, extensions, and quirks.
- Vendor Client Layer — HTTP clients configured per-vendor with appropriate auth, base URLs, headers, timeouts, and retry policies.
Auth Model Differences by Vendor
| Feature | Epic | Oracle Health | athenahealth |
|---|---|---|---|
| Protocol | SMART on FHIR (OAuth 2.0) | SMART on FHIR + System OAuth | OAuth 2.0 + API Key |
| Client Auth | JWT assertion (private_key_jwt) | Client secret or JWT | Client ID/Secret (basic auth) |
| Token Lifetime | 5 minutes | 20 minutes | 60 minutes |
| Refresh Tokens | Yes (for user-facing apps) | Limited support | Not supported (re-auth) |
| Scopes | Granular (system/Patient.read) | Coarse (system/*.read) | Flat (practice-level) |
| Multi-tenant | Per-organization endpoints | Per-tenant base URL | Practice ID in headers |
The auth differences alone justify the adapter pattern. Your application should call adapter.get_patient(id) without knowing whether that requires a JWT assertion to Epic, a client credentials flow to Oracle, or an API key to athenahealth. For details on SMART on FHIR auth in particular, see our guide on Implementing SMART on FHIR.
Building the Adapter: Python Implementation
Here is the full adapter pattern implementation. This is production-level code with proper error handling, auth management, and extensibility:
# ehr_adapter.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
import time
import logging
import httpx
import jwt
from datetime import datetime, timedelta, timezone
logger = logging.getLogger("ehr-adapter")
@dataclass
class EHRConfig:
vendor: str
base_url: str
client_id: str
client_secret: Optional[str] = None
private_key: Optional[str] = None # For JWT auth (Epic)
token_url: Optional[str] = None
practice_id: Optional[str] = None # For athenahealth
api_key: Optional[str] = None
fhir_version: str = "R4"
rate_limit: int = 10 # requests per second
class EHRAdapter(ABC):
"""Abstract base class for EHR vendor adapters."""
def __init__(self, config: EHRConfig):
self.config = config
self._token = None
self._token_expires = 0
self.client = httpx.Client(
base_url=config.base_url,
timeout=30.0,
headers={"Accept": "application/fhir+json"}
)
@abstractmethod
def authenticate(self) -> str:
"""Obtain access token. Returns token string."""
pass
def get_token(self) -> str:
"""Get valid token, refreshing if expired."""
if time.time() >= self._token_expires - 30: # 30s buffer
self._token = self.authenticate()
return self._token
def _request(self, method: str, path: str, **kwargs) -> dict:
"""Make authenticated request with retry and error handling."""
token = self.get_token()
headers = {"Authorization": f"Bearer {token}"}
headers.update(kwargs.pop("headers", {}))
for attempt in range(3):
try:
resp = self.client.request(
method, path, headers=headers, **kwargs
)
if resp.status_code == 429: # Rate limited
retry_after = int(resp.headers.get("Retry-After", 5))
logger.warning(f"Rate limited by {self.config.vendor}, waiting {retry_after}s")
time.sleep(retry_after)
continue
if resp.status_code == 401: # Token expired
self._token_expires = 0
token = self.get_token()
headers["Authorization"] = f"Bearer {token}"
continue
resp.raise_for_status()
return resp.json()
except httpx.HTTPStatusError as e:
if attempt == 2:
raise
logger.warning(f"Request failed (attempt {attempt+1}): {e}")
return {}
# --- Unified Interface ---
@abstractmethod
def get_patient(self, patient_id: str) -> dict:
pass
@abstractmethod
def search_patients(self, **params) -> list[dict]:
pass
@abstractmethod
def get_conditions(self, patient_id: str) -> list[dict]:
pass
@abstractmethod
def get_medications(self, patient_id: str) -> list[dict]:
pass
@abstractmethod
def get_observations(self, patient_id: str,
category: str = None) -> list[dict]:
pass
def normalize_patient(self, raw: dict) -> dict:
"""Override per vendor to normalize Patient resource."""
return raw
def _extract_bundle_entries(self, bundle: dict) -> list[dict]:
"""Extract resources from a FHIR Bundle."""
entries = bundle.get("entry", [])
return [e.get("resource", e) for e in entries]
def _paginate(self, initial_bundle: dict) -> list[dict]:
"""Follow pagination links to get all results."""
all_entries = self._extract_bundle_entries(initial_bundle)
bundle = initial_bundle
while True:
next_url = None
for link in bundle.get("link", []):
if link.get("relation") == "next":
next_url = link.get("url")
break
if not next_url:
break
bundle = self._request("GET", next_url)
all_entries.extend(self._extract_bundle_entries(bundle))
return all_entries
class EpicAdapter(EHRAdapter):
"""Epic EHR adapter using SMART on FHIR backend services."""
def authenticate(self) -> str:
now = datetime.now(timezone.utc)
claims = {
"iss": self.config.client_id,
"sub": self.config.client_id,
"aud": self.config.token_url,
"jti": f"epic-{int(now.timestamp())}",
"exp": int((now + timedelta(minutes=4)).timestamp()),
"iat": int(now.timestamp()),
}
assertion = jwt.encode(
claims, self.config.private_key, algorithm="RS384"
)
resp = httpx.post(
self.config.token_url,
data={
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": assertion,
}
)
resp.raise_for_status()
data = resp.json()
self._token_expires = time.time() + data.get("expires_in", 300)
return data["access_token"]
def get_patient(self, patient_id: str) -> dict:
raw = self._request("GET", f"/Patient/{patient_id}")
return self.normalize_patient(raw)
def search_patients(self, **params) -> list[dict]:
bundle = self._request("GET", "/Patient", params=params)
return [self.normalize_patient(p) for p in self._paginate(bundle)]
def get_conditions(self, patient_id: str) -> list[dict]:
bundle = self._request(
"GET", "/Condition",
params={"patient": patient_id, "category": "problem-list-item"}
)
return self._paginate(bundle)
def get_medications(self, patient_id: str) -> list[dict]:
bundle = self._request(
"GET", "/MedicationRequest",
params={"patient": patient_id, "status": "active"}
)
return self._paginate(bundle)
def get_observations(self, patient_id: str,
category: str = None) -> list[dict]:
params = {"patient": patient_id}
if category:
params["category"] = category
bundle = self._request("GET", "/Observation", params=params)
return self._paginate(bundle)
def normalize_patient(self, raw: dict) -> dict:
"""Normalize Epic-specific extensions."""
patient = dict(raw)
# Epic uses specific extension URLs for race, ethnicity
extensions = patient.get("extension", [])
for ext in extensions:
url = ext.get("url", "")
if "us-core-race" in url:
patient["_race"] = ext.get("extension", [{}])[0].get(
"valueCoding", {}).get("display")
elif "us-core-ethnicity" in url:
patient["_ethnicity"] = ext.get("extension", [{}])[0].get(
"valueCoding", {}).get("display")
return patient
class OracleAdapter(EHRAdapter):
"""Oracle Health (Cerner) adapter."""
def authenticate(self) -> str:
resp = httpx.post(
self.config.token_url,
data={
"grant_type": "client_credentials",
"scope": "system/*.read",
},
auth=(self.config.client_id, self.config.client_secret)
)
resp.raise_for_status()
data = resp.json()
self._token_expires = time.time() + data.get("expires_in", 1200)
return data["access_token"]
def get_patient(self, patient_id: str) -> dict:
raw = self._request("GET", f"/Patient/{patient_id}")
return self.normalize_patient(raw)
def search_patients(self, **params) -> list[dict]:
# Oracle uses different search parameter names in some cases
oracle_params = self._translate_search_params(params)
bundle = self._request("GET", "/Patient", params=oracle_params)
return [self.normalize_patient(p) for p in self._paginate(bundle)]
def get_conditions(self, patient_id: str) -> list[dict]:
bundle = self._request(
"GET", "/Condition",
params={"patient": patient_id}
# Note: Oracle may not support category filter
)
return self._paginate(bundle)
def get_medications(self, patient_id: str) -> list[dict]:
bundle = self._request(
"GET", "/MedicationRequest",
params={"patient": patient_id, "status": "active"}
)
return self._paginate(bundle)
def get_observations(self, patient_id: str,
category: str = None) -> list[dict]:
params = {"patient": patient_id}
if category:
params["category"] = category
bundle = self._request("GET", "/Observation", params=params)
return self._paginate(bundle)
def _translate_search_params(self, params: dict) -> dict:
"""Translate generic params to Oracle-specific params."""
translated = dict(params)
# Oracle uses 'given' instead of 'given:contains' for partial match
if "name" in translated:
translated["name"] = translated.pop("name")
return translated
class AthenaAdapter(EHRAdapter):
"""athenahealth adapter with proprietary API fallback."""
def authenticate(self) -> str:
resp = httpx.post(
self.config.token_url,
data={
"grant_type": "client_credentials",
"scope": "athena/service/Athenanet.MDP.*",
},
auth=(self.config.client_id, self.config.client_secret)
)
resp.raise_for_status()
data = resp.json()
self._token_expires = time.time() + data.get("expires_in", 3600)
return data["access_token"]
def get_patient(self, patient_id: str) -> dict:
raw = self._request("GET", f"/Patient/{patient_id}")
return self.normalize_patient(raw)
def search_patients(self, **params) -> list[dict]:
bundle = self._request("GET", "/Patient", params=params)
return [self.normalize_patient(p) for p in self._paginate(bundle)]
def get_conditions(self, patient_id: str) -> list[dict]:
bundle = self._request(
"GET", "/Condition",
params={"patient": patient_id}
)
return self._paginate(bundle)
def get_medications(self, patient_id: str) -> list[dict]:
bundle = self._request(
"GET", "/MedicationRequest",
params={"patient": patient_id}
)
return self._paginate(bundle)
def get_observations(self, patient_id: str,
category: str = None) -> list[dict]:
params = {"patient": patient_id}
if category:
params["category"] = category
bundle = self._request("GET", "/Observation", params=params)
return self._paginate(bundle)
def normalize_patient(self, raw: dict) -> dict:
"""Normalize athena-specific patient data."""
patient = dict(raw)
# athena may use practice-specific identifiers
if self.config.practice_id:
patient["_practiceId"] = self.config.practice_id
return patientConfig-Driven Vendor Registry
The registry pattern lets you select the right adapter based on configuration, without hardcoding vendor logic in your application:
# vendor_registry.py
import json
import os
from typing import Type
class VendorRegistry:
"""Registry for EHR vendor adapters. Config-driven vendor selection."""
_adapters: dict[str, Type[EHRAdapter]] = {
"epic": EpicAdapter,
"oracle": OracleAdapter,
"cerner": OracleAdapter, # Alias
"athenahealth": AthenaAdapter,
"athena": AthenaAdapter, # Alias
}
_instances: dict[str, EHRAdapter] = {}
@classmethod
def register(cls, name: str, adapter_class: Type[EHRAdapter]):
"""Register a new vendor adapter."""
cls._adapters[name.lower()] = adapter_class
@classmethod
def get_adapter(cls, org_id: str) -> EHRAdapter:
"""Get or create adapter for an organization."""
if org_id in cls._instances:
return cls._instances[org_id]
config = cls._load_config(org_id)
vendor = config.vendor.lower()
adapter_class = cls._adapters.get(vendor)
if not adapter_class:
raise ValueError(f"No adapter registered for vendor: {vendor}")
adapter = adapter_class(config)
cls._instances[org_id] = adapter
return adapter
@classmethod
def _load_config(cls, org_id: str) -> EHRConfig:
"""Load EHR config for an organization.
In production, load from secrets manager + database."""
config_path = os.getenv("EHR_CONFIG_PATH", "ehr_configs")
with open(f"{config_path}/{org_id}.json") as f:
data = json.load(f)
return EHRConfig(**data)
# Usage in your application:
# adapter = VendorRegistry.get_adapter("mercy-health")
# patient = adapter.get_patient("12345")
# conditions = adapter.get_conditions("12345")
# medications = adapter.get_medications("12345")The config file for each organization specifies the vendor and connection details:
// ehr_configs/mercy-health.json
{
"vendor": "epic",
"base_url": "https://fhir.mercy.net/interconnect-fhir-oauth/api/FHIR/R4",
"client_id": "abc123-def456",
"private_key_path": "/secrets/mercy-epic-key.pem",
"token_url": "https://fhir.mercy.net/interconnect-fhir-oauth/oauth2/token",
"fhir_version": "R4",
"rate_limit": 10
}Patient Matching Across Systems
The hardest problem in multi-EHR integration is not technical — it is patient identity. A patient at one health system has MRN E-12345. At another, they are O-67890. Same person, different identifiers. Your integration layer must resolve this.
Matching Strategies
| Strategy | Accuracy | When to Use |
|---|---|---|
| MPI Lookup | High (if maintained) | When the health system has a Master Patient Index |
| Demographics Match | Medium-High | Name + DOB + Gender matching across systems |
| Identifier Cross-Reference | High | When systems share a common ID (SSN, insurance ID) |
| FHIR $match Operation | Variable | When the target system supports the Patient/$match endpoint |
# patient_matching.py
from dataclasses import dataclass
from difflib import SequenceMatcher
@dataclass
class MatchResult:
patient_id: str
source_system: str
confidence: float # 0.0 to 1.0
match_type: str # "exact", "probabilistic", "manual_review"
class PatientMatcher:
"""Cross-system patient matching."""
CONFIDENCE_THRESHOLD = 0.85
def match_patient(self, source_patient: dict,
target_adapter: EHRAdapter) -> MatchResult | None:
"""Find matching patient in target system."""
# Strategy 1: Direct identifier match
for identifier in source_patient.get("identifier", []):
system = identifier.get("system", "")
value = identifier.get("value", "")
if system and value:
results = target_adapter.search_patients(
identifier=f"{system}|{value}"
)
if len(results) == 1:
return MatchResult(
patient_id=results[0]["id"],
source_system=target_adapter.config.vendor,
confidence=1.0,
match_type="exact"
)
# Strategy 2: Demographics-based probabilistic matching
name = self._extract_name(source_patient)
dob = source_patient.get("birthDate", "")
gender = source_patient.get("gender", "")
if name and dob:
candidates = target_adapter.search_patients(
birthdate=dob, gender=gender
)
best_match = None
best_score = 0
for candidate in candidates:
score = self._calculate_match_score(
source_patient, candidate
)
if score > best_score:
best_score = score
best_match = candidate
if best_match and best_score >= self.CONFIDENCE_THRESHOLD:
return MatchResult(
patient_id=best_match["id"],
source_system=target_adapter.config.vendor,
confidence=best_score,
match_type="probabilistic" if best_score < 1.0 else "exact"
)
return None # No match found
def _calculate_match_score(self, source: dict, target: dict) -> float:
"""Calculate probabilistic match score."""
scores = []
# Name similarity (weight: 0.35)
src_name = self._extract_name(source)
tgt_name = self._extract_name(target)
if src_name and tgt_name:
name_score = SequenceMatcher(
None, src_name.lower(), tgt_name.lower()
).ratio()
scores.append((name_score, 0.35))
# DOB exact match (weight: 0.30)
if source.get("birthDate") and target.get("birthDate"):
dob_score = 1.0 if source["birthDate"] == target["birthDate"] else 0.0
scores.append((dob_score, 0.30))
# Gender match (weight: 0.10)
if source.get("gender") and target.get("gender"):
gender_score = 1.0 if source["gender"] == target["gender"] else 0.0
scores.append((gender_score, 0.10))
# Address similarity (weight: 0.15)
src_zip = self._extract_postal(source)
tgt_zip = self._extract_postal(target)
if src_zip and tgt_zip:
zip_score = 1.0 if src_zip == tgt_zip else 0.0
scores.append((zip_score, 0.15))
# Phone match (weight: 0.10)
src_phone = self._extract_phone(source)
tgt_phone = self._extract_phone(target)
if src_phone and tgt_phone:
phone_score = 1.0 if src_phone == tgt_phone else 0.0
scores.append((phone_score, 0.10))
if not scores:
return 0.0
total_weight = sum(w for _, w in scores)
return sum(s * w for s, w in scores) / total_weight
def _extract_name(self, patient: dict) -> str:
names = patient.get("name", [])
if names:
name = names[0]
family = name.get("family", "")
given = " ".join(name.get("given", []))
return f"{given} {family}".strip()
return ""
def _extract_postal(self, patient: dict) -> str:
addresses = patient.get("address", [])
return addresses[0].get("postalCode", "") if addresses else ""
def _extract_phone(self, patient: dict) -> str:
telecoms = patient.get("telecom", [])
for t in telecoms:
if t.get("system") == "phone":
return t.get("value", "").replace("-", "").replace(" ", "")
return ""Patient matching is never fully automated in production. Set your confidence threshold high (0.85+) and route uncertain matches to a human reviewer. A false positive match — merging two different patients — is a patient safety issue. For the broader interoperability picture, see our guide on How EHR Integration Unlocks Seamless Interoperability.
Handling Vendor-Specific Quirks
Every vendor has behaviors that deviate from the FHIR specification. The adapter pattern contains these quirks in one place. Here are the most common ones we encounter:
Epic Quirks
- Token scope validation is strict — requesting a scope you are not approved for returns a 400, not a reduced token
- Custom extensions everywhere — Epic uses extensions for race/ethnicity, preferred language, and organization-specific fields
- Binary resource for documents — DocumentReference points to a Binary resource that requires a separate authenticated GET
- Search result limits — Epic caps search results at different limits per resource type (1000 for Observation, 100 for Patient)
Oracle Health Quirks
- DSTU2 fallback — Some resource types are still only available on DSTU2 endpoints at certain sites
- Different bundle format — Oracle sometimes includes
fullUrlas an absolute URL, sometimes as a relative reference - Medication data model — Oracle often uses contained Medication resources inside MedicationRequest, rather than references
- Date search precision — Some date search parameters require exact format matching (yyyy-MM-dd, not yyyy)
athenahealth Quirks
- Dual API surface — Some data is only available through the proprietary API, not FHIR
- Practice-scoped requests — Every request must include the practice ID in the URL path
- Custom search parameters — Some search parameters are athena-specific and not in the FHIR spec
- Appointment data — Scheduling data is only available through the proprietary API
The key principle: never let vendor quirks leak into your application code. Every quirk is handled inside the adapter. Your application calls adapter.get_medications(patient_id) and gets back a normalized FHIR MedicationRequest, regardless of whether Oracle returned a contained Medication resource or Epic returned a reference to a separate Medication resource. For common pitfalls in these integrations, see our guide on Common HealthTech Integration Mistakes.
Rate Limiting Across Vendors
Each vendor has different rate limit policies, and they enforce them differently:
| Vendor | Rate Limit | Enforcement | Recommended Strategy |
|---|---|---|---|
| Epic | ~10 req/sec per user token | HTTP 429 with Retry-After | Token bucket with per-org limiting |
| Oracle | ~200 req/min per app | HTTP 429, sometimes HTTP 503 | Sliding window with exponential backoff |
| athenahealth | ~150 req/min per practice | HTTP 403 with custom error body | Queue-based rate smoothing per practice |
Build the rate limiter into the adapter base class, configurable per vendor. This prevents individual organization connections from exhausting the rate limit for your entire application.
Frequently Asked Questions
Should I build my own integration layer or use a middleware vendor?
It depends on your core competency. If EHR integration is central to your product (you are a health IT company), build it. The adapter pattern gives you full control over data mapping, error handling, and performance. If EHR integration is a checkbox requirement (you are an AI company with a healthcare vertical), consider middleware like Redox, Health Gorilla, or Zus Health. The tradeoff: middleware adds cost ($0.05-0.50 per API call) and a dependency, but saves 6-12 months of engineering time.
How do I handle a new EHR vendor request?
With the adapter pattern, adding a new vendor requires: (1) implementing the EHRAdapter interface for the new vendor, (2) adding the vendor to the registry, (3) creating a config file for each organization using that vendor. No changes to your application code. Budget 2-4 weeks for a new vendor adapter, including testing.
What about FHIR Bulk Data for batch operations?
FHIR Bulk Data (the $export operation) is supported differently across vendors. Epic has robust Bulk Data support. Oracle is limited. athenahealth does not support it. Your adapter should expose a bulk_export() method that uses Bulk Data when available and falls back to paginated FHIR searches when not.
How do I test against multiple EHR sandboxes?
Maintain sandbox configurations alongside production configs. Epic (fhir.epic.com), Oracle (fhir.cerner.com), and athena (api.preview.platform.athenahealth.com) all provide sandbox environments. Create integration tests that run your adapter against each sandbox with synthetic data. Use contract testing to verify that vendor API responses match your expected schemas.
What about real-time data sync vs. on-demand queries?
Most multi-EHR integrations use on-demand queries (pull model): your app calls the adapter when it needs data. For real-time sync (push model), you need vendor-specific webhooks or subscription mechanisms. Epic supports FHIR Subscriptions (R5 backport). Oracle has limited subscription support. athenahealth uses a proprietary webhook system. The adapter pattern handles both — exposes a subscribe() method with vendor-specific implementations.
Conclusion
Building a multi-EHR integration layer is an infrastructure investment that pays off at every new customer deployment. Without it, each new health system connection is a custom engineering project. With it, onboarding a new organization is a configuration change.
Start with the adapter pattern. Implement Epic first (largest market share, best FHIR support). Add Oracle next. Then Athenahealth. For each vendor, invest heavily in the normalization layer — the differences in how vendors represent the same clinical data are where most integration bugs hide.
The vendor registry pattern keeps your application code clean. Your clinical logic, AI models, and user interface never need to know which EHR is behind the adapter. That separation is what makes multi-vendor support maintainable at scale.
For a comprehensive view of the interoperability landscape these integrations operate within, see our guides on Interoperability Standards in Healthcare and HL7 vs FHIR: When Healthcare Organizations Need Both.



