Clinical Decision Support (CDS) Hooks is how AI and evidence-based medicine enter the EHR at the exact moment a clinician makes a decision. Not after the fact in a report. Not in a separate portal they never check. Right there, inline, while they are ordering a medication, opening a patient chart, or signing an order.
The CDS Hooks specification, maintained by HL7, defines a simple REST-based pattern: the EHR fires an HTTP POST to an external service at specific workflow points (hooks), and the service returns decision support in the form of Cards that display directly in the EHR UI. It is the single most practical way to embed AI into clinical workflows today.
But here is the reality that vendor marketing will not tell you: Epic supports CDS Hooks comprehensively in production. Oracle Health (Cerner) has a limited, partial implementation that forces workarounds. If you are building a CDS service, you need to know exactly what works where, and how to architect for both.
This guide covers the full landscape: hook types, service discovery, building a production CDS service in Python/FastAPI, Epic-specific implementation details (including BPA integration), Oracle's limitations, performance requirements, and testing. By the end, you will have working code you can deploy.
How CDS Hooks Work: The Core Architecture
The CDS Hooks architecture is refreshingly simple compared to most healthcare integration patterns. There are three actors:
- EHR (Hook Invoker) — Fires hooks at specific workflow points
- CDS Service — External service that receives context and returns decisions
- Clinician — Sees the returned Cards in the EHR UI and acts on them
The flow works in four steps:
- The EHR discovers available CDS services via a
GET /cds-servicescall to the service's discovery endpoint - The EHR registers the services it wants to use
- When a clinician triggers a workflow event (opening a chart, selecting a medication), the EHR sends an HTTP POST to the relevant CDS service endpoint
- The CDS service processes the request and returns Cards — structured decision support that the EHR renders inline
The entire interaction must complete in under 500 milliseconds. That constraint shapes every architectural decision you make.
The Five Standard Hook Types
CDS Hooks defines five standard hooks, each firing at a different point in the clinical workflow:
| Hook | Fires When | Common Use Cases | Epic Support | Oracle Support |
|---|---|---|---|---|
patient-view | Clinician opens a patient chart | Care gaps, risk scores, outstanding alerts | Yes | Partial |
order-select | Clinician selects an order (medication, lab, imaging) | Drug interactions, duplicate orders, formulary checks | Yes | No |
order-sign | Clinician signs/submits an order | Prior auth requirements, dosage validation, contraindications | Yes | No |
encounter-start | Patient encounter begins | Protocol reminders, screening due, documentation templates | Yes | No |
encounter-discharge | Patient is being discharged | Discharge checklist, follow-up scheduling, medication reconciliation | Yes | No |
The hook type determines what context data the EHR sends. A patient-view hook includes the patient ID and user ID. An order-select hook includes the draft orders (draftOrders as a FHIR Bundle). An order-sign hook includes the final orders about to be signed.
Notice the Epic vs. Oracle column. This is the fundamental vendor gap: Epic supports all five standard hooks plus custom hooks. Oracle Health only partially supports patient-view and has not implemented the order or encounter hooks as of early 2026.
Service Discovery: The /cds-services Endpoint
Before any hooks fire, the EHR needs to know what services are available. Your CDS service exposes a discovery endpoint that returns a JSON manifest of all available hooks:
// GET https://your-cds-service.com/cds-services
// Response:
{
"services": [
{
"hook": "patient-view",
"title": "Diabetes Risk Assessment",
"description": "AI-powered risk scoring for undiagnosed Type 2 Diabetes",
"id": "diabetes-risk",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"conditions": "Condition?patient={{context.patientId}}&category=encounter-diagnosis",
"labs": "Observation?patient={{context.patientId}}&category=laboratory&code=4548-4&_sort=-date&_count=3",
"medications": "MedicationRequest?patient={{context.patientId}}&status=active"
}
},
{
"hook": "order-select",
"title": "Drug Interaction Checker",
"description": "Checks selected medication against active medications for interactions",
"id": "drug-interaction",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"activeMeds": "MedicationRequest?patient={{context.patientId}}&status=active"
}
}
]
}The prefetch object is critical for performance. It tells the EHR: "When you call this hook, include these FHIR resources in the request so I don't have to make separate FHIR API calls." Without prefetch, your service would need to query the EHR's FHIR server for patient data, adding 200-400ms of latency. With prefetch, the EHR bundles the data into the hook request.
The prefetch templates use a simple token replacement syntax: {{context.patientId}} gets replaced with the actual patient ID at invocation time.
Building a CDS Hooks Service with Python/FastAPI
Here is a complete, production-ready CDS Hooks service. This is not a toy example — it includes proper error handling, logging, CORS configuration, and the patterns you need for real deployment:
# cds_service.py
import logging
import time
from datetime import datetime, date
from typing import Optional
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("cds-hooks")
app = FastAPI(title="CDS Hooks Service", version="1.0.0")
# CORS is required - EHRs make cross-origin requests
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Restrict in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Models ---
class CdsCard:
def __init__(self, summary, indicator="info", detail=None,
source_label="Your CDS Service", suggestions=None,
links=None, selection_behavior=None):
self.card = {
"summary": summary,
"indicator": indicator, # info, warning, critical
"source": {"label": source_label},
}
if detail:
self.card["detail"] = detail
if suggestions:
self.card["suggestions"] = suggestions
if links:
self.card["links"] = links
if selection_behavior:
self.card["selectionBehavior"] = selection_behavior
def to_dict(self):
return self.card
# --- Discovery Endpoint ---
@app.get("/cds-services")
def discovery():
return {
"services": [
{
"hook": "patient-view",
"title": "Diabetes Risk Assessment",
"description": "Assesses Type 2 Diabetes risk using labs and conditions",
"id": "diabetes-risk",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"conditions": "Condition?patient={{context.patientId}}",
"a1c_labs": "Observation?patient={{context.patientId}}&code=4548-4&_sort=-date&_count=5",
"glucose_labs": "Observation?patient={{context.patientId}}&code=2345-7&_sort=-date&_count=5"
}
},
{
"hook": "order-select",
"title": "Drug Interaction Check",
"description": "Checks for drug-drug interactions on selected medications",
"id": "drug-interaction-check",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"active_meds": "MedicationRequest?patient={{context.patientId}}&status=active"
}
}
]
}
# --- patient-view hook ---
@app.post("/cds-services/diabetes-risk")
async def diabetes_risk_hook(request: Request):
start_time = time.time()
body = await request.json()
cards = []
try:
patient = body.get("prefetch", {}).get("patient", {})
conditions = body.get("prefetch", {}).get("conditions", {})
a1c_labs = body.get("prefetch", {}).get("a1c_labs", {})
# Extract latest A1C value
a1c_value = None
a1c_entries = a1c_labs.get("entry", []) if a1c_labs else []
if a1c_entries:
latest = a1c_entries[0].get("resource", {})
a1c_value = latest.get("valueQuantity", {}).get("value")
# Check for existing diabetes diagnosis
has_diabetes = False
condition_entries = conditions.get("entry", []) if conditions else []
diabetes_codes = {"E11", "E11.9", "E11.65", "44054006", "73211009"}
for entry in condition_entries:
resource = entry.get("resource", {})
codings = resource.get("code", {}).get("coding", [])
for coding in codings:
if coding.get("code") in diabetes_codes:
has_diabetes = True
break
# Decision logic
if a1c_value and a1c_value >= 6.5 and not has_diabetes:
cards.append(CdsCard(
summary=f"Elevated HbA1c: {a1c_value}%. Consider diabetes evaluation.",
indicator="warning",
detail=f"Patient's most recent HbA1c is {a1c_value}%, which exceeds "
f"the diagnostic threshold of 6.5% for Type 2 Diabetes. "
f"No existing diabetes diagnosis found in problem list.",
source_label="Diabetes Risk CDS",
suggestions=[{
"label": "Add Type 2 Diabetes to Problem List",
"uuid": "add-diabetes-dx",
"actions": [{
"type": "create",
"description": "Add Type 2 Diabetes Mellitus (E11.9)",
"resource": {
"resourceType": "Condition",
"code": {
"coding": [{
"system": "http://hl7.org/fhir/sid/icd-10-cm",
"code": "E11.9",
"display": "Type 2 diabetes mellitus without complications"
}]
},
"subject": {
"reference": f"Patient/{body.get('context', {}).get('patientId')}"
},
"clinicalStatus": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/condition-clinical",
"code": "active"
}]
}
}
}]
}],
links=[{
"label": "ADA Diabetes Diagnostic Criteria",
"url": "https://diabetesjournals.org/care/article/47/Supplement_1/S20/153954",
"type": "absolute"
}]
).to_dict())
elif a1c_value and 5.7 <= a1c_value < 6.5:
cards.append(CdsCard(
summary=f"Pre-diabetes range HbA1c: {a1c_value}%. Monitor recommended.",
indicator="info",
detail=f"Patient's HbA1c of {a1c_value}% falls in the pre-diabetes range "
f"(5.7-6.4%). Consider lifestyle intervention counseling and "
f"repeat testing in 3-6 months.",
source_label="Diabetes Risk CDS"
).to_dict())
except Exception as e:
logger.error(f"Error processing diabetes-risk hook: {e}")
# CDS Hooks spec: return empty cards on error, never 500
return {"cards": []}
elapsed = (time.time() - start_time) * 1000
logger.info(f"diabetes-risk completed in {elapsed:.0f}ms, returned {len(cards)} cards")
return {"cards": cards}
# --- order-select hook ---
@app.post("/cds-services/drug-interaction-check")
async def drug_interaction_hook(request: Request):
start_time = time.time()
body = await request.json()
cards = []
try:
draft_orders = body.get("context", {}).get("draftOrders", {})
active_meds = body.get("prefetch", {}).get("active_meds", {})
# Extract active medication codes
active_rxnorms = set()
for entry in active_meds.get("entry", []):
resource = entry.get("resource", {})
codings = resource.get("medicationCodeableConcept", {}).get("coding", [])
for coding in codings:
if coding.get("system") == "http://www.nlm.nih.gov/research/umls/rxnorm":
active_rxnorms.add(coding.get("code"))
# Extract draft order medication codes
for entry in draft_orders.get("entry", []):
resource = entry.get("resource", {})
if resource.get("resourceType") != "MedicationRequest":
continue
draft_codings = resource.get("medicationCodeableConcept", {}).get("coding", [])
for coding in draft_codings:
rxnorm_code = coding.get("code")
display = coding.get("display", "Unknown")
# Check against interaction database
interaction = check_drug_interaction(rxnorm_code, active_rxnorms)
if interaction:
cards.append(CdsCard(
summary=f"Drug Interaction: {display} with {interaction['conflicting_drug']}",
indicator=interaction["severity"],
detail=interaction["description"],
source_label="Drug Interaction CDS",
suggestions=[{
"label": f"Consider alternative: {interaction['alternative']}",
"uuid": f"alt-{rxnorm_code}"
}] if interaction.get("alternative") else None
).to_dict())
except Exception as e:
logger.error(f"Error in drug-interaction hook: {e}")
return {"cards": []}
elapsed = (time.time() - start_time) * 1000
logger.info(f"drug-interaction completed in {elapsed:.0f}ms")
return {"cards": cards}
def check_drug_interaction(new_code, active_codes):
"""Check for known drug-drug interactions.
In production, this queries NLM's RxNav API or a local interaction DB."""
# Example interaction rules (replace with real DB)
INTERACTIONS = {
("8787", "11289"): { # metformin + warfarin
"conflicting_drug": "Warfarin",
"severity": "warning",
"description": "Metformin may enhance the anticoagulant effect of Warfarin.",
"alternative": "Consider closer INR monitoring"
},
}
for active in active_codes:
pair = tuple(sorted([new_code, active]))
if pair in INTERACTIONS:
return INTERACTIONS[pair]
return None
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8080)This service handles two hooks: a patient-view hook for diabetes risk assessment and an order-select hook for drug interaction checking. Notice the patterns that matter for production: timing every request, returning empty cards on errors (never HTTP 500s — the CDS Hooks spec requires this), using prefetch data instead of making FHIR calls, and structured logging.
Anatomy of a CDS Card: What You Can Return
The Card is the fundamental unit of CDS Hooks output. Each card represents one piece of decision support shown to the clinician. The structure has more power than most implementations use:
| Field | Required | Description | Example |
|---|---|---|---|
summary | Yes | One-liner shown as the card title (max 140 chars) | "Elevated HbA1c: 7.2%. Consider diabetes evaluation." |
indicator | Yes | Urgency level: info, warning, critical | "warning" |
detail | No | Markdown-formatted detail text | Expanded clinical reasoning |
source | Yes | Label and optional URL for the service providing the card | {"label": "Drug Interaction DB"} |
suggestions | No | Actions the clinician can take with one click | Create/update/delete FHIR resources |
links | No | External links: absolute URLs or SMART app launches | Link to evidence, launch SMART app |
selectionBehavior | No | How to handle multiple suggestions: any, at-most-one | "at-most-one" for mutually exclusive options |
The suggestions field is where real workflow integration happens. A suggestion can include actions that modify FHIR resources directly:
{
"suggestions": [
{
"label": "Order HbA1c Lab",
"uuid": "order-a1c-001",
"isRecommended": true,
"actions": [
{
"type": "create",
"description": "Order Hemoglobin A1c test",
"resource": {
"resourceType": "ServiceRequest",
"status": "draft",
"intent": "order",
"code": {
"coding": [{
"system": "http://loinc.org",
"code": "4548-4",
"display": "Hemoglobin A1c/Hemoglobin.total in Blood"
}]
},
"subject": {
"reference": "Patient/123"
}
}
}
]
}
]
}The links field supports two types: absolute links (open a URL) and smart links (launch a SMART on FHIR app in context):
{
"links": [
{
"label": "View Interaction Details",
"url": "https://www.drugs.com/interaction/check",
"type": "absolute"
},
{
"label": "Open Risk Calculator",
"url": "https://your-smart-app.com/launch",
"type": "smart",
"appContext": "{\"screen\": \"risk-calc\", \"patientId\": \"123\"}"
}
]
}SMART app links are powerful because they launch a full SMART on FHIR application within the EHR, pre-loaded with the patient context. This bridges the gap between a simple card notification and a rich interactive experience. For more on SMART on FHIR, see our guide on Implementing SMART on FHIR With Local-First Storage.
Epic-Specific Implementation: BPA Integration
Epic is the gold standard for CDS Hooks adoption. Their implementation goes beyond the base specification with tight integration into Epic's Best Practice Advisory (BPA) system. Here is what you need to know:
App Orchard Registration
Every CDS Hooks service must be registered through Epic's App Orchard (now App Market). The registration requires:
- CDS service discovery URL (must be HTTPS in production)
- OAuth 2.0 client credentials for your service
- FHIR scopes your prefetch templates require
- A signed agreement covering data use, security, and support
BPA Integration Points
Epic maps CDS Hooks to their BPA framework. This means your CDS cards appear alongside Epic's native BPAs in the clinician's workflow. The mapping works as follows:
| CDS Hooks Concept | Epic BPA Equivalent | Behavior |
|---|---|---|
| Card with indicator "critical" | Hard Stop BPA | Clinician must acknowledge before proceeding |
| Card with indicator "warning" | Soft Stop BPA | Clinician sees alert, can dismiss |
| Card with indicator "info" | Informational BPA | Passive notification, no action required |
| Suggestion with action | BPA Action | One-click order or documentation |
| SMART app link | BPA Linked App | Opens SMART app in Epic sidebar |
Epic Prefetch Behavior
Epic has specific behaviors around prefetch that differ from the spec:
- Prefetch is always honored — Epic will attempt to fulfill all prefetch templates and include the data
- Null prefetch values — If a prefetch query returns no results, Epic sends
nullfor that key (not an empty Bundle) - Prefetch token replacement — Epic supports
{{context.patientId}}and{{context.userId}}tokens - Prefetch limits — Epic caps prefetch queries at 1000 resources per query. Use
_countand_sortto get what you need
FHIR Authorization for CDS Services
When your CDS service needs to make additional FHIR calls beyond prefetch (not recommended for performance, but sometimes necessary), Epic provides a fhirAuthorization object in the hook request:
{
"hook": "patient-view",
"hookInstance": "d1577c69-dfbe-44ad-b1e9-1a8c75a4b34a",
"fhirServer": "https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
"fhirAuthorization": {
"access_token": "eyJhbGc...",
"token_type": "Bearer",
"expires_in": 300,
"scope": "patient/Patient.read patient/Condition.read",
"subject": "Practitioner/abc123"
},
"context": {
"userId": "Practitioner/abc123",
"patientId": "patient-xyz"
},
"prefetch": {
"patient": { ... }
}
}This is a short-lived token scoped to the current patient. Use it only when prefetch cannot satisfy your data needs. The integration patterns for Epic and other EHRs are covered extensively in our EHR Integration Interoperability guide.
Oracle Health (Cerner) Limitations and Workarounds
Oracle Health's CDS Hooks support is significantly behind Epic. Here is the current state as of early 2026:
What Oracle Supports
patient-viewhook — partially supported through their Ignite platform- Basic card rendering (summary, indicator, source)
- Absolute links in cards
What Oracle Does Not Support
order-selectandorder-signhooks — not availableencounter-startandencounter-dischargehooks — not available- Suggestion actions (create/update/delete FHIR resources) — limited support
- SMART app links in cards — not consistently available
- Prefetch templates — limited implementation; your service may need to query FHIR directly
Workarounds for Oracle
If you must support Oracle Health alongside Epic, here are the practical approaches:
- Webhook-based alerting — Use Oracle's MPages framework to embed custom content that calls your CDS service separately, outside the CDS Hooks pattern
- SMART on FHIR launch — Build a SMART app that performs the decision support logic at launch time, since Oracle's SMART on FHIR support is more mature than their CDS Hooks
- HL7v2 ADT triggers — For encounter-start/discharge equivalents, use ADT event feeds to trigger your CDS logic, then surface results through a SMART app or MPages view
- Polling model — Periodically check for new orders/encounters through FHIR APIs and push notifications through Oracle's native alerting
None of these workarounds are as clean as native CDS Hooks. The industry consensus is that Oracle is approximately 2-3 years behind Epic in CDS Hooks adoption. Plan your architecture accordingly: build for CDS Hooks first (Epic), with an adapter layer for Oracle-specific fallbacks. For guidance on building these multi-vendor integration layers, see our deep dive on Common HealthTech Integration Mistakes.
Performance Requirements: The 500ms Budget
CDS Hooks has a hard performance constraint: the EHR will timeout your service if it does not respond within approximately 10 seconds, but clinicians will ignore cards that take more than 500ms to appear. Epic's internal documentation recommends a 300ms target. Here is how to budget your latency:
| Phase | Budget | Notes |
|---|---|---|
| Network round-trip | 50ms | Deploy near the EHR data center or use CDN |
| TLS handshake (first request) | 50ms | Use HTTP/2 and connection pooling |
| Request parsing + validation | 10ms | Validate required fields only, skip schema validation |
| Prefetch data extraction | 20ms | Prefetch data is in the request body, no I/O |
| Business logic + ML inference | 200ms | Your actual CDS computation |
| FHIR queries (if needed) | 150ms | Avoid if possible; use prefetch instead |
| Response serialization | 10ms | JSON response is small |
| Total | <500ms |
Performance Optimization Strategies
# Performance patterns for CDS Hooks services
import asyncio
import hashlib
import json
from functools import lru_cache
from typing import Any
import aiohttp
import redis.asyncio as redis
# 1. Redis cache for expensive computations
cache = redis.Redis(host='localhost', port=6379, decode_responses=True)
async def get_cached_risk_score(patient_id: str, a1c: float) -> dict | None:
"""Cache risk scores for 1 hour - labs don't change every request."""
cache_key = f"cds:diabetes-risk:{patient_id}:{a1c}"
cached = await cache.get(cache_key)
if cached:
return json.loads(cached)
return None
async def set_cached_risk_score(patient_id: str, a1c: float, score: dict):
cache_key = f"cds:diabetes-risk:{patient_id}:{a1c}"
await cache.setex(cache_key, 3600, json.dumps(score))
# 2. Pre-warm ML models at startup (not per-request)
class ModelRegistry:
_models = {}
@classmethod
def load_models(cls):
"""Load all ML models at service startup."""
# Load model once, reuse for every request
cls._models["diabetes_risk"] = load_diabetes_model()
cls._models["drug_interaction"] = load_interaction_db()
@classmethod
def get_model(cls, name: str):
return cls._models.get(name)
# 3. Parallel prefetch fallback (when EHR doesn't send prefetch)
async def fetch_missing_prefetch(fhir_base: str, token: str,
patient_id: str, missing_keys: list) -> dict:
"""Fetch missing prefetch data in parallel."""
queries = {
"conditions": f"Condition?patient={patient_id}",
"a1c_labs": f"Observation?patient={patient_id}&code=4548-4&_sort=-date&_count=3",
"medications": f"MedicationRequest?patient={patient_id}&status=active"
}
headers = {"Authorization": f"Bearer {token}", "Accept": "application/fhir+json"}
results = {}
async with aiohttp.ClientSession() as session:
tasks = []
for key in missing_keys:
if key in queries:
url = f"{fhir_base}/{queries[key]}"
tasks.append((key, session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=2))))
# Execute all FHIR queries concurrently
responses = await asyncio.gather(
*[task[1] for task in tasks], return_exceptions=True
)
for (key, _), response in zip(tasks, responses):
if not isinstance(response, Exception):
async with response:
results[key] = await response.json()
return resultsThe key insight is that most CDS logic can run entirely on prefetch data without any external I/O. Structure your services so the happy path requires zero network calls during the hook request. For deep architectural patterns on building performant healthcare backends, our article on Why Real-Time Architecture in Health Apps Matters covers the underlying principles.
Testing Your CDS Hooks Service
Testing CDS Hooks requires a multi-stage approach because you cannot simply deploy to a production EHR to see if your service works.
Stage 1: Unit Testing with Mock Requests
# test_cds_service.py
import pytest
from httpx import AsyncClient, ASGITransport
from cds_service import app
@pytest.fixture
def patient_view_request():
return {
"hook": "patient-view",
"hookInstance": "test-instance-001",
"fhirServer": "https://fhir.example.com/r4",
"context": {
"userId": "Practitioner/dr-smith",
"patientId": "patient-123"
},
"prefetch": {
"patient": {
"resourceType": "Patient",
"id": "patient-123",
"birthDate": "1965-04-22",
"gender": "male"
},
"conditions": {
"resourceType": "Bundle",
"type": "searchset",
"entry": []
},
"a1c_labs": {
"resourceType": "Bundle",
"type": "searchset",
"entry": [{
"resource": {
"resourceType": "Observation",
"code": {"coding": [{"code": "4548-4", "system": "http://loinc.org"}]},
"valueQuantity": {"value": 7.2, "unit": "%"},
"effectiveDateTime": "2026-02-15"
}
}]
}
}
}
@pytest.mark.asyncio
async def test_elevated_a1c_returns_warning(patient_view_request):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/cds-services/diabetes-risk",
json=patient_view_request)
assert response.status_code == 200
data = response.json()
assert len(data["cards"]) == 1
assert data["cards"][0]["indicator"] == "warning"
assert "7.2" in data["cards"][0]["summary"]
@pytest.mark.asyncio
async def test_normal_a1c_returns_no_cards(patient_view_request):
# Set A1c to normal range
entry = patient_view_request["prefetch"]["a1c_labs"]["entry"][0]
entry["resource"]["valueQuantity"]["value"] = 5.0
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/cds-services/diabetes-risk",
json=patient_view_request)
assert response.status_code == 200
assert len(response.json()["cards"]) == 0
@pytest.mark.asyncio
async def test_missing_prefetch_returns_empty_cards():
"""Service should never crash - return empty cards on bad input."""
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post("/cds-services/diabetes-risk",
json={"hook": "patient-view", "context": {}})
assert response.status_code == 200
assert response.json() == {"cards": []}
@pytest.mark.asyncio
async def test_discovery_endpoint():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/cds-services")
assert response.status_code == 200
services = response.json()["services"]
assert len(services) >= 2
hooks = [s["hook"] for s in services]
assert "patient-view" in hooks
assert "order-select" in hooksStage 2: CDS Hooks Sandbox
The CDS Hooks Sandbox is a browser-based testing tool that simulates an EHR calling your service. To use it:
- Deploy your service to a publicly accessible URL (use ngrok for local development:
ngrok http 8080) - Open the sandbox and enter your discovery endpoint URL
- The sandbox will fetch your services and let you trigger hooks with configurable patient data
- You can see the raw request/response JSON alongside the rendered cards
Stage 3: EHR Sandbox Testing
Both Epic and Oracle provide sandbox environments:
- Epic: Access through fhir.epic.com — requires App Orchard registration. Epic's sandbox supports full CDS Hooks testing with synthetic patient data.
- Oracle: Access through code.cerner.com — limited CDS Hooks support in sandbox. Focus on patient-view hook testing.
Production Deployment Architecture
Deploying a CDS Hooks service for production use in health systems requires enterprise-grade infrastructure:
# docker-compose.yml for CDS Hooks service
version: '3.8'
services:
cds-service:
build: .
ports:
- "8080:8080"
environment:
- REDIS_URL=redis://redis:6379
- LOG_LEVEL=INFO
- FHIR_TIMEOUT_MS=2000
- MAX_CARDS_PER_RESPONSE=5
deploy:
replicas: 3
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/cds-services"]
interval: 10s
timeout: 3s
retries: 3
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
prometheus:
image: prom/prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
volumes:
redis-data:Security Requirements for Production
- HTTPS required — Epic and Oracle both require TLS 1.2+ for production CDS services
- OAuth 2.0 token validation — Validate the bearer token in each hook request against the EHR's authorization server
- Audit logging — Log every hook request with patient ID, user ID, timestamp, and cards returned (required for HIPAA compliance)
- PHI handling — Prefetch data contains PHI. Ensure your service does not persist patient data beyond the request lifecycle unless explicitly authorized
- Rate limiting — Implement rate limiting to prevent runaway EHR integrations from overwhelming your service
Real-World CDS Hooks Use Cases in Production
The most impactful CDS Hooks implementations in production today include:
- Opioid prescribing guidelines (CDC) — The CDC's opioid prescribing guideline CDS service uses order-sign hooks to check if a patient is being prescribed opioids when alternatives exist, if the dose exceeds recommended limits, or if concurrent benzodiazepines are active.
- Sepsis early warning — patient-view hooks that compute SOFA/qSOFA scores from recent labs and vitals, alerting clinicians to early sepsis indicators.
- Prior authorization — order-sign hooks that check payer formularies and prior auth requirements before orders are submitted, reducing downstream denials by 30-40%. See our deep dive on How AI Agents Are Eliminating the Prior Authorization Bottleneck.
- Clinical trial matching — patient-view hooks that match patients to open clinical trials based on their conditions, labs, and demographics.
- Care gap closure — patient-view hooks that surface overdue screenings, vaccinations, and preventive care based on HEDIS/quality measures.
Conclusion
CDS Hooks is the most practical way to embed clinical decision support — including AI-powered intelligence — directly into the EHR workflow. The specification is simple, the integration pattern is clean, and Epic's production support is comprehensive.
The vendor gap between Epic and Oracle is real and will influence your architecture. Build for CDS Hooks first, design an adapter layer for Oracle-specific fallbacks, and structure your services to handle both gracefully.
The 500ms performance constraint is not negotiable. Use prefetch aggressively, pre-load ML models, cache expensive computations, and deploy close to your EHR customers. If you are building AI-driven decision support for healthcare, CDS Hooks is your entry point. Start with a patient-view hook, test in the sandbox, and iterate toward production.
For deeper context on the healthcare integration landscape, explore our guides on The Mental Model for Healthcare Integrations and Interoperability Standards in Healthcare.



