Building a Multi-EHR Integration Layer: One API to Rule Epic, Oracle Health, and athenahealth
Digital Growth Lead
Writes about healthcare technology, interoperability, and AI-driven transformation across modern care systems.

Part of our complete guide to how Epic FHIR integration works.
Technically reviewed by Jitendra Choudhary, CTO, Nirmitee.io.
If you build a healthcare application that connects to one EHR, you have covered only part of the US market. Epic and Oracle Health (Cerner) lead the hospital market, while ambulatory care is spread across many vendors. athenahealth is one of the largest ambulatory platforms, and KLAS named athenaOne the 2026 Best in KLAS Overall Independent Physician Practice Suite. The rest of the market is fragmented across MEDITECH, Veradigm (formerly Allscripts), eClinicalWorks, and many smaller vendors. Each has its own rules: eClinicalWorks' FHIR API, for example, gives every practice its own base URL and caps each at 250 calls per minute.
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 guide reflects the architecture patterns and vendor differences our healthcare engineering team evaluates when building multi-EHR products.
What Is an EHR Adapter?
An EHR adapter is a software module that translates between one EHR vendor's API and a single internal interface your application uses. Each adapter handles that vendor's login method, FHIR version, data quirks, paging and rate limits, so the rest of your product calls one API no matter which EHR sits behind it.
A multi-EHR integration layer is a set of these adapters behind one shared contract: one adapter for Epic, one for Oracle Health, one for athenahealth, and so on. When the shared interface already covers what the new EHR offers, adding that EHR means writing one new adapter, with little or no change to your application code. This guide shows how to design that layer and build the adapters in Python.
| Option | What it is | Good fit when | Main trade-off |
|---|---|---|---|
| Your own EHR adapter layer | Adapters you build and run in your own code | EHR data is core to your product and you need control over mapping and cost | You own maintenance as vendor APIs change |
| Unified API vendor | A third party that exposes one API over many EHRs | You need many EHRs quickly and can accept their data model and fees | Less control over mapping, and a dependency on the vendor |
| Interface engine (for example Mirth Connect) | Middleware that routes and transforms HL7 v2, FHIR and other messages | You receive HL7 v2 feeds from hospitals or need message routing | Strong for messaging; you still design the shared data model |
Many teams combine them: an interface engine for Mirth Connect-style HL7 v2 feeds, and an adapter layer for FHIR APIs.
If Oracle Health is your first target, read our Cerner API integration guide before you design the layer.
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 OAuth 2.0 only:
client_credentialsfor 2-legged backend apps and the SMART authorization code flow for 3-legged apps. - 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: FHIR searches return a Bundle with a link of relation
next; follow that URL exactly as the server gives it and do not build page URLs yourself. Oracle asks apps to handle paging on every resource and to use_countfor page size (Oracle FHIR FAQ). athenaOne proprietary (non-FHIR) endpoints page differently; handle them in the athena client, not in shared paging code. - 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. Our EHR integration API comparison of Epic, Oracle Health, athenahealth and eClinicalWorks documents those vendor differences in more detail. 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: client_credentials for 2-legged, SMART authorization code (PKCE required for native and single-page apps) for 3-legged |
| Client Auth | JWT assertion (private_key_jwt) | Client secret or JWT | Client secret (basic auth), private_key_jwt with a JWKS, or PKCE (3-legged only) |
| Token Lifetime | 5 minutes | 20 minutes | 60 minutes |
| Refresh Tokens | Yes (for user-facing apps) | Limited support | 3-legged only (90-day default); 2-legged requests a new token |
| Scopes | Granular (system/Patient.read) | Coarse (system/*.read) | Standard SMART system/, patient/ and user/ scopes, plus athena/service/Athenanet.MDP.* for athenaOne APIs |
| Multi-tenant | Per-organization endpoints | Per-tenant base URL | Practice ID in the athenaOne path; ah-practice parameter on FHIR |
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 a 2-legged OAuth token from athenahealth's token endpoint, as described in its authorization overview. For details on SMART on FHIR auth in particular, see our guide on Implementing SMART on FHIR. For the athenahealth side specifically, our athenahealth API and FHIR developer guide covers how access and authentication actually work.
If you support one EHR today and the next customer runs another, discuss your multi-EHR architecture with our team. Tell us which EHR you support now, which customer or vendor comes next and what your product needs to read or write.
Building the Adapter: Python Implementation
Here is the full adapter pattern implementation. This is a reference implementation of the pattern. The error handling below raises a typed error instead of hiding failures, and it ships with unit tests for the failure cases. Test it against each vendor sandbox before production use:
# ehr_adapter.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
import time
import uuid
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 EHRIntegrationError(Exception):
"""Terminal failure from a vendor call. Logs carry no PHI or tokens."""
def __init__(self, vendor, status, retryable, correlation_id, reason=""):
self.vendor, self.status = vendor, status
self.retryable, self.correlation_id = retryable, correlation_id
super().__init__(f"{vendor} request failed: HTTP {status} {reason}".strip()
+ f" (request {correlation_id})")
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 an authenticated request. Raises EHRIntegrationError on failure;
never returns an empty dict to hide an error."""
correlation_id = uuid.uuid4().hex
headers = {"X-Request-ID": correlation_id}
headers.update(kwargs.pop("headers", {}))
refreshed = False
for attempt in range(3):
headers["Authorization"] = f"Bearer {self.get_token()}"
resp = self.client.request(method, path, headers=headers, **kwargs)
if resp.status_code == 401 and not refreshed:
self._token_expires = 0 # force one new token, then retry once
refreshed = True
continue
if resp.status_code == 429 and attempt < 2:
wait = int(resp.headers.get("Retry-After", 5))
logger.warning("Rate limited by %s, waiting %ss (%s)",
self.config.vendor, wait, correlation_id)
time.sleep(wait)
continue
if resp.status_code >= 400:
raise EHRIntegrationError(
vendor=self.config.vendor, status=resp.status_code,
retryable=resp.status_code in (429, 502, 503, 504),
correlation_id=correlation_id)
return resp.json()
raise EHRIntegrationError(vendor=self.config.vendor, status=resp.status_code,
retryable=resp.status_code == 429,
correlation_id=correlation_id)
# --- 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, max_pages: int = 50) -> list[dict]:
"""Follow the server's "next" links as given, up to max_pages."""
all_entries = self._extract_bundle_entries(initial_bundle)
bundle = initial_bundle
for _ in range(max_pages - 1):
next_url = next((l.get("url") for l in bundle.get("link", [])
if l.get("relation") == "next"), None)
if not next_url:
return all_entries
bundle = self._request("GET", next_url)
all_entries.extend(self._extract_bundle_entries(bundle))
if any(l.get("relation") == "next" for l in bundle.get("link", [])):
raise EHRIntegrationError(vendor=self.config.vendor, status=0,
retryable=False, correlation_id="",
reason=f"more than {max_pages} pages; narrow the search")
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: If you would rather not build this layer yourself, compare these EHR integration companies.
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
athenahealth Quirks
- Dual API surface: Some data is only available through the proprietary API, not FHIR
- Practice-scoped requests: athenaOne API paths carry the practice ID (
/v1/{practiceid}/...), while FHIR R4 searches pass it as theah-practiceparameter, which athenahealth's Patient reference requires on system-scope searches that do not use_id - Custom search parameters: Some search parameters are athena-specific and not in the FHIR spec
- Appointment data: FHIR R4 Appointment supports read and search, but there is no FHIR Slot or Appointment create, so finding open slots and booking run through the athenaOne appointment APIs (see our FHIR scheduling API guide)
If athenahealth is the next adapter on your roadmap, our athenahealth integration services page sets out which workflows run on FHIR and which need the athenaOne APIs, and how an app moves from Preview to practice enablement.
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.
Safe retries for writes across vendors
What happens if a create request times out and you retry it? You may create the same record twice. Never retry a POST blindly:
- Use a conditional create (the
If-None-Existheader) where the vendor's CapabilityStatement says it is supported. - Otherwise, search for the record by your own client identifier before retrying, and only create it if the search finds nothing.
- Store the resource id the vendor returns, and log the request id (
X-Request-ID) so support teams can trace the call.
Example: when filing a DocumentReference, set an identifier from your own system, search DocumentReference?identifier=yoursystem|123 after a timeout, and create only if no match comes back.
Rate Limiting Across Vendors
The table below covers what each vendor publishes. Where no number is published, agree limits with each customer and always read HTTP 429 and the Retry-After header.
Each vendor has different rate limit policies, and they enforce them differently:
| Vendor | Rate Limit | Enforcement | Recommended Strategy |
|---|---|---|---|
| Epic | No fixed public number in the documentation we checked; limits are set per customer environment | HTTP 429 with Retry-After | Token bucket with per-org limiting |
| Oracle | "Rate limits vary between customers based on agreed usage levels" (Oracle FHIR FAQ) | HTTP 429, sometimes HTTP 503 | Sliding window with exponential backoff |
| athenahealth | 150 calls per second and 500,000 per day in production (15 and 50,000 in preview), per its support FAQ | HTTP 403 "Developer Over Rate" | Queue-based rate smoothing across all practices, heavy reads off-hours |
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.
Planning a multi-EHR layer that includes athenahealth? Our healthcare interoperability solutions team designs the adapters, the normalization layer and the vendor onboarding path. Talk to our team for a fixed-scope architecture review.
EHR Adapter Checklist Before You Add a New Vendor
Each new EHR adapter should answer the same questions before it goes to production. Use this list in design review:
- Access path: how the app is registered with the vendor, and whether each customer site must approve it separately.
- Auth flow: which OAuth 2.0 or SMART on FHIR flows are supported, token lifetime and refresh rules.
- FHIR version and resources: which resources and search parameters the vendor supports, and which need a proprietary API.
- Write-back: which resources can be created or updated, not only read.
- Rate limits and errors: the documented limits and the exact status code returned when you exceed them.
- Identifiers: which patient and encounter identifier systems the vendor returns, for patient matching.
- Sandbox: whether a public sandbox exists and how close its data is to production.
- Contract tests: a fixed set of tests every adapter must pass against the shared interface before release.
Need help scoping adapters for your EHR list? See our EHR integration services and healthcare product engineering work.
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.
Adding a second EHR without rebuilding your product?
Tell us which EHR you support today, which customer or vendor comes next and what your product needs to read or write. Discuss adapter boundaries, access dependencies and a practical sequence for expanding your integration.
Frequently Asked Questions
Should I build my own integration layer or use a middleware vendor?
How do I handle a new EHR vendor request?
What about FHIR Bulk Data for batch operations?
How do I test against multiple EHR sandboxes?
What about real-time data sync vs. on-demand queries?
How does Nirmitee.io approach healthcare software development?
What is an EHR adapter?
Is an EHR adapter the same as an interface engine?
Do I need a separate adapter for each EHR vendor?
How do I test a new EHR adapter?

