Interoperability

Building a Multi-EHR Integration Layer: One API to Rule Epic, Oracle Health, and athenahealth

March 21, 202610 min readUpdated Nov 4, 2026
Written by
Dinesh Thorat
Dinesh Thorat

Digital Growth Lead

Writes about healthcare technology, interoperability, and AI-driven transformation across modern care systems.

Building a Multi-EHR Integration Layer: One API to Rule Epic, Oracle Health, and athenahealth

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.

OptionWhat it isGood fit whenMain trade-off
Your own EHR adapter layerAdapters you build and run in your own codeEHR data is core to your product and you need control over mapping and costYou own maintenance as vendor APIs change
Unified API vendorA third party that exposes one API over many EHRsYou need many EHRs quickly and can accept their data model and feesLess 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 messagesYou receive HL7 v2 feeds from hospitals or need message routingStrong 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_credentials for 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 _count for 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:

  1. Unified FHIR API: The interface your application uses. Consistent request/response format regardless of the downstream EHR.
  2. Vendor Adapter Layer: Per-vendor adapters that translate between your unified format and vendor-specific APIs, auth flows, extensions, and quirks.
  3. Vendor Client Layer: HTTP clients configured per-vendor with appropriate auth, base URLs, headers, timeouts, and retry policies.

Auth Model Differences by Vendor

FeatureEpicOracle Healthathenahealth
ProtocolSMART on FHIR (OAuth 2.0)SMART on FHIR + System OAuthOAuth 2.0: client_credentials for 2-legged, SMART authorization code (PKCE required for native and single-page apps) for 3-legged
Client AuthJWT assertion (private_key_jwt)Client secret or JWTClient secret (basic auth), private_key_jwt with a JWKS, or PKCE (3-legged only)
Token Lifetime5 minutes20 minutes60 minutes
Refresh TokensYes (for user-facing apps)Limited support3-legged only (90-day default); 2-legged requests a new token
ScopesGranular (system/Patient.read)Coarse (system/*.read)Standard SMART system/, patient/ and user/ scopes, plus athena/service/Athenanet.MDP.* for athenaOne APIs
Multi-tenantPer-organization endpointsPer-tenant base URLPractice 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 patient

Config-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

StrategyAccuracyWhen to Use
MPI LookupHigh (if maintained)When the health system has a Master Patient Index
Demographics MatchMedium-HighName + DOB + Gender matching across systems
Identifier Cross-ReferenceHighWhen systems share a common ID (SSN, insurance ID)
FHIR $match OperationVariableWhen 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 fullUrl as 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 the ah-practice parameter, 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-Exist header) 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:

VendorRate LimitEnforcementRecommended Strategy
EpicNo fixed public number in the documentation we checked; limits are set per customer environmentHTTP 429 with Retry-AfterToken bucket with per-org limiting
Oracle"Rate limits vary between customers based on agreed usage levels" (Oracle FHIR FAQ)HTTP 429, sometimes HTTP 503Sliding window with exponential backoff
athenahealth150 calls per second and 500,000 per day in production (15 and 50,000 in preview), per its support FAQHTTP 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?

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. The adapter code is usually the smaller part: vendor access approval, customer registration and site-specific testing set the schedule, so estimate after those are known.

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 supports Group-level $export on one practice-wide Group (not in its public sandbox) and positions it for initial loads and monthly syncs, per its Bulk Export workflow . 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 offers Event Notifications , FHIR Subscription webhooks based on the R5 Backport (rest-hook, id-only) for 2-legged apps, alongside polling changed data subscriptions. The adapter pattern handles both, exposes a subscribe() method with vendor-specific implementations.

How does Nirmitee.io approach healthcare software development?

Nirmitee.io follows a healthcare-first engineering approach, every solution is built with HIPAA compliance, HL7/FHIR interoperability standards, and clinical workflow requirements at the core. We combine domain expertise with modern engineering practices to deliver production-ready systems.

What is an EHR adapter?

An EHR adapter is a software module that converts one EHR vendor's API (login, FHIR version, data format, paging and limits) into a single internal interface. Your application talks to the interface; the adapter talks to the EHR.

Is an EHR adapter the same as an interface engine?

No. An interface engine such as Mirth Connect routes and transforms messages, often HL7 v2, between systems. An EHR adapter is a component, inside your product or run as its own service, that hides vendor API differences. Many teams use both.

Do I need a separate adapter for each EHR vendor?

Usually yes, one adapter per vendor, all implementing the same interface. Adapters for FHIR R4 vendors can share common base code, but supported resources, searches and operations still differ, so assess the extra work for each vendor separately.

How do I test a new EHR adapter?

Run the same contract test suite against every adapter using the vendor sandbox, then repeat the key tests against a customer test environment, since sandbox data and production settings often differ.
Share