Your email address will not be published. Required fields are marked *
Our expert reaches out shortly after receiving your request and analyzing your requirements.
If needed, we sign an NDA to protect your privacy.
We request additional information to better understand and analyze your project.
We schedule a call to discuss your project, goals. and priorities, and provide preliminary feedback.
If you're satisfied, we finalize the agreement and start your project.

SMART on FHIR is an open-standards-based framework that allows developers to build applications that integrate with any EHR system supporting the SMART specification. It was originally developed at Boston Children’s Hospital and Harvard Medical School, and is now maintained by HL7 International as a core part of the FHIR ecosystem.
The framework solves a fundamental problem in healthcare IT: before SMART on FHIR, building an application that worked inside an EHR required proprietary integration with each vendor. An app built for Epic could not run in Cerner without a complete rebuild. SMART on FHIR eliminates this by providing a standardized launch protocol, authorization mechanism, and data access layer.
What SMART provides:
What SMART does NOT provide:
The SMART on FHIR workflow has three phases:
Your app discovers the EHR’s FHIR server capabilities by fetching the server’s metadata:
GET {fhir-base-url}/.well-known/smart-configuration
This returns a JSON document containing:
authorization_endpoint — where to send the user for authenticationtoken_endpoint — where to exchange authorization codes for access tokensscopes_supported — which FHIR scopes the server supportscapabilities — which SMART features are supported (launch-ehr, launch-standalone, etc.)Alternatively, you can fetch the FHIR CapabilityStatement:
GET {fhir-base-url}/metadata
This returns the server’s FHIR capabilities including supported resources, search parameters, and security extensions containing the OAuth endpoints.
Why discovery matters: Different EHR systems have different OAuth endpoints, different supported scopes, and different capabilities. Your app must dynamically discover these rather than hardcoding them. This is what makes your app portable across EHR platforms.
Your app requests authorization to access data on behalf of the user:
authorization_endpoint with your requested scopestoken_endpointYour app uses the access token to make FHIR API calls:
GET {fhir-base-url}/Patient/{id} Authorization: Bearer {access-token}
The access token is scoped to specific FHIR resources and specific patients based on the launch context and requested scopes. Your app cannot access data outside the authorized scope.
The SMART launch framework defines how your app receives context from the EHR — which patient is selected, which encounter is active, which user is logged in.
When an EHR launches your app, it provides context through the OAuth token response:
| Context Parameter | Description | Example Value |
|---|---|---|
patient | FHIR Patient resource ID | “12345” |
encounter | FHIR Encounter resource ID | “67890” |
fhirUser | FHIR resource URL of the logged-in user | “Practitioner/11111” |
need_patient_banner | Whether EHR is already showing patient info | true/false |
smart_style_url | URL to EHR’s style guide for visual consistency | URL string |
tenant | Tenant identifier for multi-tenant systems | “hospital-a” |
patient is the most critical parameter. It tells your app which patient’s data to access. In an EHR launch, this is automatically set to the patient whose chart the clinician has open. In a standalone launch, the user selects a patient during the authorization flow.
When registering your app with an EHR, you provide:
The EHR appends a launch parameter and an iss (issuer) parameter to your Launch URL:
https://your-app.com/launch?iss=https://fhir.hospital.com/r4&launch=abc123
Your app uses the iss to discover the authorization endpoint and the launch to correlate the session with the EHR context.
How it works: A clinician is working in the EHR, has a patient chart open, and clicks a button or menu item to launch your app. The EHR passes the patient context to your app automatically.
Authorization URL parameters:
{authorization_endpoint}?
response_type=code&
client_id={your-client-id}&
redirect_uri={your-redirect-url}&
scope=launch openid fhirUser patient/*.read&
state={random-state-value}&
aud={fhir-base-url}&
launch={launch-token-from-ehr}
Key parameter: launch — this token links the authorization request to the specific EHR session and patient context.
Best for: Clinical workflow applications, clinical decision support tools, order entry assistants, documentation aids, prior authorization tools — any app that needs to operate in the context of a specific patient encounter.
How it works: Your app launches independently — from a browser bookmark, a mobile app icon, or any other entry point outside the EHR. The user authenticates and then selects a patient.
Authorization URL parameters:
{authorization_endpoint}?
response_type=code&
client_id={your-client-id}&
redirect_uri={your-redirect-url}&
scope=launch/patient openid fhirUser patient/*.read&
state={random-state-value}&
aud={fhir-base-url}
Key difference: Instead of the launch parameter, the scope includes launch/patient, which tells the authorization server to present a patient picker during the authorization flow.
Best for: Patient-facing apps, administrative tools, reporting applications, data analytics dashboards — apps that do not need to be embedded within the EHR clinical workflow.
Most apps should support both launch types. EHR launch for clinical workflow integration and standalone launch for use outside the EHR. The authorization logic is nearly identical — the only difference is how patient context is obtained (automatically from EHR vs patient picker).
SMART on FHIR uses OAuth 2.0 Authorization Code Grant with Proof Key for Code Exchange (PKCE) for public clients.
Step 1: Discover OAuth endpoints
const smartConfig = await fetch(`${fhirBaseUrl}/.well-known/smart-configuration`)
.then(r => r.json());
const authUrl = smartConfig.authorization_endpoint;
const tokenUrl = smartConfig.token_endpoint;
Step 2: Generate PKCE code verifier and challenge
function generateCodeVerifier() {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function generateCodeChallenge(verifier) {
const hash = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(verifier));
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
Step 3: Redirect to authorization endpoint
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Store codeVerifier in session for later use
const authParams = new URLSearchParams({
response_type: 'code',
client_id: YOUR_CLIENT_ID,
redirect_uri: YOUR_REDIRECT_URI,
scope: 'launch openid fhirUser patient/*.read',
state: generateRandomState(),
aud: fhirBaseUrl,
launch: launchToken, // only for EHR launch
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `${authUrl}?${authParams}`;
Step 4: Handle the redirect callback
After the user authenticates, the authorization server redirects to your redirect URI with an authorization code:
https://your-app.com/callback?code=AUTH_CODE&state=YOUR_STATE
Step 5: Exchange code for access token
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: YOUR_REDIRECT_URI,
client_id: YOUR_CLIENT_ID,
code_verifier: storedCodeVerifier
})
}).then(r => r.json());
const accessToken = tokenResponse.access_token;
const patientId = tokenResponse.patient; // patient context
const encounterId = tokenResponse.encounter; // encounter context (if available)
const idToken = tokenResponse.id_token; // user identity (if openid scope requested)
Step 6: Use the access token for FHIR API calls
const patient = await fetch(`${fhirBaseUrl}/Patient/${patientId}`, {
headers: { 'Authorization': `Bearer ${accessToken}` }
}).then(r => r.json());
Access tokens are short-lived (5–60 minutes depending on the EHR). If the token response includes a refresh_token, use it to obtain new access tokens without re-authorization:
const refreshResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: storedRefreshToken,
client_id: YOUR_CLIENT_ID
})
}).then(r => r.json());
SMART scopes define what data your app can access. Scopes follow a specific format:
Format: patient/{resource-type}.{permission}
patient/Patient.read — read the patient’s demographic datapatient/Observation.read — read lab results and vitalspatient/MedicationRequest.read — read medication orderspatient/Condition.read — read diagnoses and problemspatient/AllergyIntolerance.read — read allergiespatient/Procedure.read — read procedurespatient/Immunization.read — read immunizationspatient/DocumentReference.read — read clinical documentspatient/Encounter.read — read encounter historypatient/*.read — read all resource types for the patientpatient/Observation.write — write observations (vitals, patient-reported data)Format: user/{resource-type}.{permission}
User-level scopes grant access based on the logged-in user’s permissions within the EHR — not limited to a single patient.
user/Patient.read — read any patient the user has access touser/Practitioner.read — read practitioner informationuser/*.read — read all resources the user has permission to accessFormat: system/{resource-type}.{permission}
System-level scopes are for backend services (no user interaction). Used with the Client Credentials grant.
system/Patient.read — read all patientssystem/*.read — read all resourcessystem/Group.read — required for Bulk Data Exportlaunch — indicates EHR launch (receive launch context)launch/patient — indicates standalone launch (patient picker)openid — request OpenID Connect identity tokenfhirUser — include the FHIR user reference in the tokenRequest minimum necessary scopes. Only request access to the specific resource types your app needs. An app that only displays medications should request patient/MedicationRequest.read, not patient/*.read. EHR authorization servers may reject overly broad scope requests, and patients are more likely to consent to specific, limited access.
Separate read and write scopes. If your app only needs to read data, do not request write scopes. Write access requires additional review and approval from EHR vendors.
The fhirclient JavaScript library handles the SMART launch flow, authorization, and FHIR API calls. It is the recommended approach for web-based SMART apps.
Install:
npm install fhirclient
EHR Launch — launch.html:
import FHIR from 'fhirclient';
FHIR.oauth2.authorize({
clientId: 'your-client-id',
scope: 'launch openid fhirUser patient/Patient.read patient/Observation.read patient/MedicationRequest.read patient/Condition.read patient/AllergyIntolerance.read',
redirectUri: '/app'
});
App page — app.html:
import FHIR from 'fhirclient';
async function loadApp() {
const client = await FHIR.oauth2.ready();
// Get patient demographics
const patient = await client.patient.read();
console.log(`Patient: ${patient.name[0].given[0]} ${patient.name[0].family}`);
console.log(`DOB: ${patient.birthDate}`);
console.log(`Gender: ${patient.gender}`);
// Get active conditions
const conditions = await client.request(
`Condition?patient=${client.patient.id}&clinical-status=active`
);
// Get active medications
const medications = await client.request(
`MedicationRequest?patient=${client.patient.id}&status=active`
);
// Get recent lab results
const labs = await client.request(
`Observation?patient=${client.patient.id}&category=laboratory&_sort=-date&_count=20`
);
// Get allergies
const allergies = await client.request(
`AllergyIntolerance?patient=${client.patient.id}`
);
// Render data in your UI
renderPatientSummary(patient, conditions, medications, labs, allergies);
}
loadApp();
FHIR search queries return Bundle resources containing the matching entries:
const bundle = await client.request(
`Condition?patient=${client.patient.id}&clinical-status=active`
);
if (bundle.entry) {
const conditions = bundle.entry.map(e => e.resource);
conditions.forEach(condition => {
const code = condition.code?.coding?.[0]?.display || 'Unknown';
const icd10 = condition.code?.coding?.find(c =>
c.system === 'http://hl7.org/fhir/sid/icd-10-cm'
)?.code;
console.log(`${code} (${icd10})`);
});
}
For large result sets, follow the Bundle’s pagination links:
async function getAllResults(client, url) {
let results = [];
let bundle = await client.request(url);
while (bundle) {
if (bundle.entry) {
results = results.concat(bundle.entry.map(e => e.resource));
}
const nextLink = bundle.link?.find(l => l.relation === 'next');
bundle = nextLink ? await client.request(nextLink.url) : null;
}
return results;
}
const allLabs = await getAllResults(client,
`Observation?patient=${client.patient.id}&category=laboratory`
);
const patient = await client.patient.read();
const name = `${patient.name[0].given.join(' ')} ${patient.name[0].family}`;
const dob = patient.birthDate;
const gender = patient.gender;
const mrn = patient.identifier?.find(id =>
id.type?.coding?.[0]?.code === 'MR'
)?.value;
const phone = patient.telecom?.find(t => t.system === 'phone')?.value;
const email = patient.telecom?.find(t => t.system === 'email')?.value;
const address = patient.address?.[0];
const vitals = await client.request(
`Observation?patient=${client.patient.id}&category=vital-signs&_sort=-date&_count=10`
);
vitals.entry?.forEach(e => {
const obs = e.resource;
const type = obs.code?.coding?.[0]?.display;
const value = obs.valueQuantity?.value;
const unit = obs.valueQuantity?.unit;
const date = obs.effectiveDateTime;
console.log(`${type}: ${value} ${unit} (${date})`);
});
const labs = await client.request(
`Observation?patient=${client.patient.id}&category=laboratory&_sort=-date&_count=50`
);
labs.entry?.forEach(e => {
const obs = e.resource;
const testName = obs.code?.coding?.[0]?.display;
const loinc = obs.code?.coding?.find(c =>
c.system === 'http://loinc.org'
)?.code;
const value = obs.valueQuantity?.value || obs.valueString;
const unit = obs.valueQuantity?.unit || '';
const refLow = obs.referenceRange?.[0]?.low?.value;
const refHigh = obs.referenceRange?.[0]?.high?.value;
const abnormal = obs.interpretation?.[0]?.coding?.[0]?.code; // H, L, A, etc.
const date = obs.effectiveDateTime;
});
const meds = await client.request(
`MedicationRequest?patient=${client.patient.id}&status=active`
);
meds.entry?.forEach(e => {
const med = e.resource;
const drugName = med.medicationCodeableConcept?.coding?.[0]?.display
|| med.medicationCodeableConcept?.text;
const rxnorm = med.medicationCodeableConcept?.coding?.find(c =>
c.system === 'http://www.nlm.nih.gov/research/umls/rxnorm'
)?.code;
const dosage = med.dosageInstruction?.[0]?.text;
const prescriber = med.requester?.display;
const dateWritten = med.authoredOn;
});
const notes = await client.request(
`DocumentReference?patient=${client.patient.id}&type=clinical-note&_sort=-date&_count=10`
);
notes.entry?.forEach(e => {
const doc = e.resource;
const noteType = doc.type?.coding?.[0]?.display;
const date = doc.date;
const author = doc.author?.[0]?.display;
// Get the actual note content
const attachment = doc.content?.[0]?.attachment;
if (attachment?.data) {
const noteText = atob(attachment.data); // Base64 decode
} else if (attachment?.url) {
// Fetch the note content from the URL
const noteContent = await client.request(attachment.url);
}
});
Write operations require specific write scopes and additional approval from EHR vendors. Common write use cases:
const observation = {
resourceType: 'Observation',
status: 'final',
category: [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/observation-category',
code: 'vital-signs',
display: 'Vital Signs'
}]
}],
code: {
coding: [{
system: 'http://loinc.org',
code: '85354-9',
display: 'Blood pressure panel'
}]
},
subject: { reference: `Patient/${client.patient.id}` },
effectiveDateTime: new Date().toISOString(),
component: [
{
code: { coding: [{ system: 'http://loinc.org', code: '8480-6', display: 'Systolic BP' }] },
valueQuantity: { value: 120, unit: 'mmHg', system: 'http://unitsofmeasure.org', code: 'mm' }
},
{
code: { coding: [{ system: 'http://loinc.org', code: '8462-4', display: 'Diastolic BP' }] },
valueQuantity: { value: 80, unit: 'mmHg', system: 'http://unitsofmeasure.org', code: 'mm' }
}
]
};
const created = await client.create(observation);
const docRef = {
resourceType: 'DocumentReference',
status: 'current',
type: {
coding: [{
system: 'http://loinc.org',
code: '34133-9',
display: 'Summary of episode note'
}]
},
subject: { reference: `Patient/${client.patient.id}` },
date: new Date().toISOString(),
content: [{
attachment: {
contentType: 'text/plain',
data: btoa('Clinical assessment summary text here...')
}
}]
};
const created = await client.create(docRef);
CDS Hooks extend SMART on FHIR by enabling your application to provide clinical decision support at specific workflow trigger points within the EHR.
| Hook | Trigger | Use Case |
|---|---|---|
patient-view | Clinician opens a patient chart | Care gap alerts, risk scores, prior visit summaries |
order-select | Clinician selects an order | Drug interaction warnings, formulary checks, prior auth requirements |
order-sign | Clinician signs orders | Final validation, cost alerts, guideline compliance checks |
encounter-start | New encounter begins | Screening reminders, protocol suggestions |
encounter-discharge | Discharge workflow initiated | Discharge checklist, follow-up scheduling reminders |
appointment-book | Appointment being scheduled | Pre-visit preparation reminders, eligibility checks |
Your service exposes a discovery endpoint and a hook endpoint:
Discovery: GET https://your-cds-service.com/cds-services
{
"services": [{
"hook": "patient-view",
"title": "Diabetes Risk Assessment",
"description": "Evaluates patient risk factors for Type 2 diabetes",
"id": "diabetes-risk",
"prefetch": {
"patient": "Patient/{{context.patientId}}",
"conditions": "Condition?patient={{context.patientId}}&clinical-status=active",
"labs": "Observation?patient={{context.patientId}}&category=laboratory&code=4548-4&_sort=-date&_count=1"
}
}]
}
Hook invocation: POST https://your-cds-service.com/cds-services/diabetes-risk
The EHR sends context and prefetched data. Your service responds with cards:
{
"cards": [{
"summary": "HbA1c trending upward — consider diabetes screening",
"detail": "Patient's last HbA1c was 6.2% (3 months ago). Combined with BMI of 31 and family history, this patient meets ADA screening criteria.",
"indicator": "warning",
"source": {
"label": "Taction Diabetes Risk Engine",
"url": "https://your-app.com/info"
},
"suggestions": [{
"label": "Order HbA1c test",
"actions": [{
"type": "create",
"description": "Order HbA1c",
"resource": {
"resourceType": "ServiceRequest",
"code": { "coding": [{ "system": "http://loinc.org", "code": "4548-4" }] },
"subject": { "reference": "Patient/12345" }
}
}]
}],
"links": [{
"label": "View full risk assessment",
"url": "https://your-app.com/launch",
"type": "smart"
}]
}]
}
Minimize alert fatigue. Only return cards when clinically significant. If your service returns cards for every patient, clinicians will ignore all of them. Target a card rate below 15–20% of patient encounters.
Keep cards concise. The summary should be actionable in one sentence. Use detail for supporting evidence. Clinicians spend seconds, not minutes, reviewing CDS cards.
Provide actionable suggestions. Cards with specific, one-click actions (order a test, update a diagnosis) are acted upon 3–5x more frequently than informational-only cards.
Respond fast. EHRs impose timeout limits on CDS Hook calls (typically 5–10 seconds). Your service must respond within this window. Pre-compute risk scores and cache results where possible.
Epic’s SMART on FHIR implementation is the most widely deployed in the US hospital market.
Registration: Register your app through Epic’s App Orchard/Showroom. Specify SMART launch type, required scopes, and redirect URIs.
Epic-specific considerations:
patient, encounter, and fhirUser context in the token responsepatient-view, order-select, and order-signEpic-specific scopes: Epic supports standard SMART scopes plus Epic-specific extensions for certain resource types. Always check Epic’s FHIR documentation for the exact scope syntax for each resource.
Oracle Health (Cerner) Millennium supports SMART on FHIR through its Ignite API platform.
Registration: Register through the Oracle Health developer portal. Create an application with SMART launch capabilities.
Oracle Health-specific considerations:
patient-view, order-select, and order-signAthenahealth supports SMART on FHIR alongside its proprietary REST APIs.
Registration: Register through the athenahealth developer portal. Configure SMART launch for Marketplace integration.
Athenahealth-specific considerations:
For applications that need to access FHIR data without user interaction — background synchronization, analytics pipelines, population health tools — SMART defines the Backend Services authorization profile.
Backend services authenticate using JWT assertions signed with a private key:
const jwt = createSignedJWT({
iss: YOUR_CLIENT_ID,
sub: YOUR_CLIENT_ID,
aud: tokenUrl,
exp: Math.floor(Date.now() / 1000) + 300,
jti: generateUniqueId()
}, YOUR_PRIVATE_KEY);
const tokenResponse = await fetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: 'system/*.read',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: jwt
})
}).then(r => r.json());
Backend services can use the FHIR Bulk Data Export specification to export large datasets:
// Initiate export
const exportResponse = await fetch(`${fhirBaseUrl}/Patient/$export`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/fhir+json',
'Prefer': 'respond-async'
}
});
const statusUrl = exportResponse.headers.get('Content-Location');
// Poll for completion
let status;
do {
await new Promise(resolve => setTimeout(resolve, 10000)); // wait 10 seconds
status = await fetch(statusUrl, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
} while (status.status === 202);
// Download results
const result = await status.json();
const fileUrls = result.output.map(f => f.url);
Backend services are essential for healthcare data analytics platforms and remote patient monitoring systems that need ongoing data access without clinician interaction.
Public clients (browser-based apps, mobile apps) must use PKCE (Proof Key for Code Exchange) to prevent authorization code interception attacks. The code_verifier and code_challenge parameters in the OAuth flow implement this protection.
Confidential clients (server-side apps with a client secret) should also use PKCE as a defense-in-depth measure.
EHR FHIR servers typically support CORS for browser-based SMART apps. However, some EHR implementations require server-side API calls (your backend proxies FHIR requests) rather than direct browser-to-FHIR calls. Test CORS behavior in each EHR’s sandbox.
If your SMART app runs in an iframe within the EHR, Content Security Policy headers may restrict which external resources your app can load. Test iframe behavior early in development.
| Sandbox | URL | Description |
|---|---|---|
| SMART Health IT Sandbox | launch.smarthealthit.org | Open sandbox for testing SMART launch flows |
| Logica Health Sandbox | sandbox.logicahealth.org | Full FHIR R4 sandbox with synthetic data |
| Epic App Orchard Sandbox | Through Epic developer portal | Epic-specific FHIR sandbox |
| Oracle Health Sandbox | Through Cerner Code portal | Millennium-specific sandbox |
| Athenahealth Preview | Through athenahealth developer portal | Athenahealth-specific sandbox |
SMART App Launcher: The SMART Health IT Launcher (launch.smarthealthit.org) allows you to simulate EHR launches without an actual EHR. Configure patient context, user context, and supported scopes to test your app’s launch flow.
FHIR Request Inspector: Log all FHIR API requests and responses during development. The fhirclient library supports debug mode that logs HTTP requests.
Token Decoder: Use jwt.io to decode and inspect access tokens and ID tokens. Verify that scopes, patient context, and token expiration are correct.
Browser Developer Tools: Monitor network requests to track OAuth redirects, FHIR API calls, and error responses. The Authorization Code flow involves multiple redirects that can be difficult to follow without network monitoring.
“Invalid scope” error during authorization: The requested scope is not supported by the EHR or not approved for your application. Check the SMART configuration endpoint for scopes_supported and verify your registered scopes match.
“Invalid redirect URI” error: The redirect URI in your authorization request does not match exactly what you registered with the EHR. Check for trailing slashes, protocol differences (http vs https), and port numbers.
Empty patient context: The token response does not include a patient parameter. Verify your scope includes launch (for EHR launch) or launch/patient (for standalone launch).
CORS errors on FHIR API calls: The EHR’s FHIR server is blocking browser-to-server requests. Use a backend proxy or confirm that the EHR supports CORS for your application’s origin.
Each EHR has its own marketplace and approval process:
Epic App Orchard/Showroom: Submit through the Epic developer portal. Expect 2–4 months for review. Each Epic customer site must independently activate your app. See our Epic integration guide for detailed steps.
Oracle Health (Cerner Code): Submit through the Oracle Health developer portal. Expect 4–8 weeks for review. Per-site activation required. See our Oracle Health integration guide for details.
Athenahealth Marketplace: Submit through the athenahealth developer portal. Expect 6–12 weeks for review. No per-site activation — approved apps work for all athenahealth customers. See our athenahealth integration guide for the Marketplace process.
For apps supporting multiple EHR platforms, use a configuration-driven architecture:
Hardcoding FHIR server URLs. Your app must dynamically discover the FHIR server URL from the iss parameter during launch. Hardcoded URLs break when deploying to different EHR instances or when EHR infrastructure changes.
Ignoring token expiration. Access tokens expire. Implement token refresh logic and handle 401 (Unauthorized) responses gracefully by refreshing the token and retrying the request.
Not handling missing data. FHIR resources have optional fields. A Patient resource may not have a phone number. A Condition may not have an onset date. An Observation may not have a reference range. Your app must handle null/undefined values for every field without crashing.
Requesting too many scopes. Broad scopes (patient/*.read) may be rejected by EHR authorization servers or raise flags during marketplace review. Request only the specific resource types your app needs.
Not supporting both launch types. Building only EHR launch or only standalone launch limits your app’s utility. Support both with shared application logic.
Slow app load time. SMART apps launch in clinical workflows where seconds matter. Optimize your initial load — defer non-critical data fetches, use loading states, and lazy-load components. If your app takes more than 3 seconds to display useful content after launch, clinicians will not use it.
Not testing with real EHR sandboxes. Public FHIR sandboxes behave differently from Epic, Oracle Health, and athenahealth sandboxes. Always test against the specific EHR sandbox before submitting for marketplace review.
Ignoring CDS Hooks response time limits. EHRs time out CDS Hook requests after 5–10 seconds. If your CDS service is slow, the EHR discards your response and the clinician never sees your cards. Pre-compute results and cache aggressively.
SMART on FHIR is the standard for building interoperable healthcare applications. Mastering this framework gives your application access to every major EHR platform in the United States through a single technical architecture.
If you are building a SMART on FHIR application and need guidance on multi-EHR deployment, CDS Hooks integration, or marketplace approval, connect with our interoperability team or explore our healthcare interoperability services.
Related Resources:
This tutorial was developed by the healthcare interoperability team at Taction Software, based on production SMART on FHIR implementations deployed across Epic, Oracle Health, and athenahealth environments for US healthcare organizations and health tech companies.
No. A single SMART on FHIR app can work across all three platforms. The SMART launch framework and FHIR R4 API are standardized. You will need separate app registrations with each EHR vendor, and you should test against each vendor’s sandbox, but the core application code is shared.
Yes, but write access is more restricted than read access. You must request write scopes, and the EHR vendor must approve those scopes during the marketplace review process. Common write use cases include creating Observations (patient-reported data, device readings), creating DocumentReferences (clinical documents), and creating ServiceRequests (orders). Each EHR has different write capabilities — check the vendor’s FHIR documentation.
SMART on FHIR’s standard OAuth flow is designed for interactive sessions. For background access, use the Backend Services authorization profile with client credentials. This requires a separate app registration with system-level scopes and is subject to additional approval from EHR vendors.
Any language that can handle HTTP requests and OAuth 2.0. JavaScript (with the fhirclient library) is most common for web-based SMART apps. Python, Java, C#, and Swift all have FHIR client libraries. Mobile SMART apps use Swift (iOS) or Kotlin (Android) with FHIR client libraries.
Your SMART app must implement all standard HIPAA safeguards: encrypt data in transit (HTTPS), minimize local data storage, implement session timeout, audit log all data access, and handle PHI according to your BAA. The SMART framework handles authorization and scoping, but your app is responsible for everything that happens after you receive the data.
FHIR is the data standard — it defines resources, search parameters, and API conventions. SMART on FHIR adds the launch framework (how apps start and receive context from the EHR) and the authorization framework (how apps get permission to access data). You can use FHIR APIs without SMART (direct API calls with API keys or service accounts), but SMART is required for apps that launch from within the EHR and need user-context-aware authorization.
A simple read-only SMART app (display patient data) can be built in 2–4 weeks. A full-featured clinical workflow app with CDS Hooks and write operations typically takes 2–5 months. Add 2–4 months for EHR marketplace review and approval per vendor. Taction Software’s multi-EHR SMART app deployments average 4–7 months from development start to production availability across all three major EHR platforms.