EHR Integration Guide
This guide shows how to go from EHR data to a shareable SMART Health Link. Whether your system uses HL7v2, CCDA, FHIR R4, or a flat database, the SDK handles the conversion to an IPS (International Patient Summary) bundle.
The Pipeline
Every EHR integration follows four steps:
- Extract — Pull clinical data from your EHR (medications, conditions, labs, immunizations)
- Map codes — Convert internal codes to standard terminologies (NDC, ICD-10, LOINC, CVX)
- Build IPS Bundle — Use the SDK to create a FHIR-compliant IPS document
- Create SHL — Encrypt the bundle and generate a shareable QR code
Prerequisites
npm install @fhirfly-io/shl @fhirfly-io/terminology
import { IPS, SHL } from "@fhirfly-io/shl";
import Fhirfly from "@fhirfly-io/terminology";
// FHIRfly API enriches your codes with display names, SNOMED mappings, etc.
// Optional but recommended — without it, you provide display names manually.
const fhirfly = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY });
Do I need a FHIRfly API key? No. The SDK works without enrichment — you pass
code,system, anddisplaymanually. Enrichment adds SNOMED cross-mappings, standardized display names, and drug ingredients automatically. Get a free API key →
Common EHR Data Formats
| Source Format | Approach |
|---|---|
| HL7v2 (ADT, ORM, ORU) | Parse segments (PID, OBX, RXA), extract codes, use SDK builders |
| CCDA (XML) | Parse sections, extract <code> elements, use SDK builders |
| FHIR R4 resources | Pass directly via fromResource — no mapping needed |
| Flat database / CSV | Query your tables, map columns to SDK input fields |
| Epic/Cerner API | FHIR R4 — use fromResource for most resources |
Use Case 1: Immunization Records
Your EHR stores CVX codes for each vaccination. The SDK enriches them with full vaccine names.
const bundle = new IPS.Bundle({
given: "Maria",
family: "Garcia",
birthDate: "1985-03-15",
gender: "female",
});
// From your EHR: CVX code 208 = COVID-19 Pfizer
bundle.addImmunization({
byCVX: "208",
fhirfly: fhirfly.cvx,
occurrenceDate: "2024-01-15",
});
// Multiple immunizations
bundle.addImmunization({
byCVX: "197",
fhirfly: fhirfly.cvx,
occurrenceDate: "2023-09-20",
});
// If you don't have CVX codes, use manual input
bundle.addImmunization({
code: "08",
system: "http://hl7.org/fhir/sid/cvx",
display: "Hepatitis B vaccine",
occurrenceDate: "2023-06-01",
});
const fhirBundle = await bundle.build();
Use Case 2: Lab Results
Your lab system stores LOINC codes. The SDK enriches them with component names, units, and reference ranges.
const bundle = new IPS.Bundle({
given: "Maria", family: "Garcia", birthDate: "1985-03-15",
});
// Glucose — numeric result with units
bundle.addResult({
byLOINC: "2339-0",
fhirfly: fhirfly.loinc,
value: 95,
unit: "mg/dL",
referenceRange: { low: 70, high: 100, unit: "mg/dL" },
effectiveDate: "2024-12-01",
});
// HbA1c
bundle.addResult({
byLOINC: "4548-4",
fhirfly: fhirfly.loinc,
value: 5.7,
unit: "%",
effectiveDate: "2024-12-01",
});
// Qualitative result (non-numeric)
bundle.addResult({
byLOINC: "5671-3",
fhirfly: fhirfly.loinc,
valueString: "Positive",
effectiveDate: "2024-11-15",
});
const fhirBundle = await bundle.build();
Use Case 3: Medication Lists
Your pharmacy system may store NDC codes, RxNorm CUIs, or internal formulary IDs. The SDK supports multiple input paths.
const bundle = new IPS.Bundle({
given: "Maria", family: "Garcia", birthDate: "1985-03-15",
});
// By NDC (11-digit) — most common in pharmacy systems
bundle.addMedication({
byNDC: "00071015523",
fhirfly: fhirfly.ndc,
status: "active",
dosageText: "500mg twice daily",
});
// By RxNorm CUI — common in EHR formularies
bundle.addMedication({
byRxNorm: "861004",
fhirfly: fhirfly.rxnorm,
status: "active",
});
// By SNOMED — when you have a clinical drug concept
bundle.addMedication({
bySNOMED: "387207008",
fhirfly: fhirfly.snomed,
status: "active",
});
// Manual — when you have your own code system
bundle.addMedication({
code: "MED-12345",
system: "https://myhospital.org/formulary",
display: "Metformin 500mg tablet",
status: "active",
dosageText: "500mg twice daily with meals",
});
const fhirBundle = await bundle.build();
Mapping Internal Formulary IDs
If your EHR uses internal medication IDs rather than standard codes, you need a mapping step:
// Your EHR's medication record
const ehrMedication = {
internalId: "FORM-8842",
name: "Metformin 500mg",
ndc: "00071015523", // Most EHRs store NDC alongside internal IDs
rxnorm: "861004", // Some also store RxNorm
};
// Use whichever standard code is available
if (ehrMedication.ndc) {
bundle.addMedication({ byNDC: ehrMedication.ndc, fhirfly: fhirfly.ndc });
} else if (ehrMedication.rxnorm) {
bundle.addMedication({ byRxNorm: ehrMedication.rxnorm, fhirfly: fhirfly.rxnorm });
} else {
// Fallback: use your internal code system
bundle.addMedication({
code: ehrMedication.internalId,
system: "https://myhospital.org/formulary",
display: ehrMedication.name,
});
}
Use Case 4: Conditions / Diagnoses
Your EHR stores ICD-10-CM codes. The SDK enriches them with display names and SNOMED CT cross-mappings.
const bundle = new IPS.Bundle({
given: "Maria", family: "Garcia", birthDate: "1985-03-15",
});
// Type 2 diabetes
bundle.addCondition({
byICD10: "E11.9",
fhirfly: fhirfly.icd10,
clinicalStatus: "active",
onsetDate: "2020-06-15",
});
// Essential hypertension
bundle.addCondition({
byICD10: "I10",
fhirfly: fhirfly.icd10,
clinicalStatus: "active",
});
// By SNOMED (when you have SNOMED codes directly)
bundle.addCondition({
bySNOMED: "44054006",
display: "Type 2 diabetes mellitus",
clinicalStatus: "active",
});
const fhirBundle = await bundle.build();
Working with Existing FHIR Resources
If your EHR exposes a FHIR R4 API (Epic, Cerner, etc.), you can pass resources directly without any mapping:
const bundle = new IPS.Bundle({
given: "Maria", family: "Garcia", birthDate: "1985-03-15",
});
// Fetch from your EHR's FHIR API
const medications = await ehrFhirClient.search("MedicationStatement", {
patient: "Patient/12345",
});
const conditions = await ehrFhirClient.search("Condition", {
patient: "Patient/12345",
});
// Pass FHIR resources directly — no mapping needed
for (const med of medications) {
bundle.addMedication({ fromResource: med });
}
for (const cond of conditions) {
bundle.addCondition({ fromResource: cond });
}
const fhirBundle = await bundle.build();
The fromResource path passes the FHIR resource through as-is, wrapping it in the IPS Composition structure. Use this when your source data is already valid FHIR.
Combining Multiple Data Types
A realistic integration pulls data from multiple sources and combines them into a single IPS bundle.
import { IPS, SHL } from "@fhirfly-io/shl";
import Fhirfly from "@fhirfly-io/terminology";
import { readFile } from "fs/promises";
const fhirfly = new Fhirfly({ apiKey: process.env.FHIRFLY_API_KEY });
// 1. Build the bundle from your EHR data
const bundle = new IPS.Bundle({
given: "Maria",
family: "Garcia",
birthDate: "1985-03-15",
gender: "female",
phone: "555-0123",
identifier: { system: "https://myhospital.org/mrn", value: "MRN-12345" },
});
// Medications from pharmacy system
bundle.addMedication({ byNDC: "00071015523", fhirfly: fhirfly.ndc, status: "active" });
bundle.addMedication({ byRxNorm: "861004", fhirfly: fhirfly.rxnorm, status: "active" });
// Conditions from problem list
bundle.addCondition({ byICD10: "E11.9", fhirfly: fhirfly.icd10, clinicalStatus: "active" });
bundle.addCondition({ byICD10: "I10", fhirfly: fhirfly.icd10, clinicalStatus: "active" });
// Allergies
bundle.addAllergy({ bySNOMED: "387207008", clinicalStatus: "active", criticality: "high" });
// Immunizations from immunization registry
bundle.addImmunization({ byCVX: "208", fhirfly: fhirfly.cvx, occurrenceDate: "2024-01-15" });
bundle.addImmunization({ byCVX: "197", fhirfly: fhirfly.cvx, occurrenceDate: "2023-09-20" });
// Labs from LIS
bundle.addResult({ byLOINC: "2339-0", fhirfly: fhirfly.loinc, value: 95, unit: "mg/dL" });
bundle.addResult({ byLOINC: "4548-4", fhirfly: fhirfly.loinc, value: 5.7, unit: "%" });
// Attach a PDF lab report
const labReport = await readFile("./lab-report.pdf");
bundle.addDocument({ title: "Lab Report - December 2024", content: labReport });
// 2. Build the FHIR Bundle
const fhirBundle = await bundle.build();
// 3. Validate (optional but recommended)
const validation = bundle.validate();
if (!validation.valid) {
console.warn("Validation issues:", validation.issues);
}
// 4. Create the SHL
const storage = new SHL.FhirflyStorage({
apiKey: process.env.FHIRFLY_API_KEY,
});
const result = await SHL.create({
bundle: fhirBundle,
storage,
passcode: "831592",
label: "Maria Garcia — Health Summary",
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
});
console.log("SHL URL:", result.url);
console.log("QR Code:", result.qrCode); // data:image/png;base64,...
Common Pitfalls
Missing FHIRfly client on byNDC/byICD10/byRxNorm/byCVX/byLOINC
These code-based inputs require the fhirfly parameter for API enrichment. The SDK will throw a clear error if you omit it:
// This will throw — byNDC requires fhirfly for enrichment
bundle.addMedication({ byNDC: "00071015523" });
// Correct
bundle.addMedication({ byNDC: "00071015523", fhirfly: fhirfly.ndc });
// Or use manual input (no API needed)
bundle.addMedication({ code: "00071015523", system: "http://hl7.org/fhir/sid/ndc", display: "Metformin" });
Debug mode in production
SHL.create({ debug: true }) stores an unencrypted copy of the bundle alongside the JWE. Never enable this in production — it defeats the zero-knowledge architecture.
Stale or incorrect codes
NDC codes change frequently (reformulations, repackaging). ICD-10 updates annually. Always validate codes against current data. The FHIRfly API returns 404 for unknown codes — handle this gracefully:
try {
bundle.addMedication({ byNDC: ehrRecord.ndc, fhirfly: fhirfly.ndc });
} catch (err) {
// Fallback to manual input if code lookup fails
bundle.addMedication({
code: ehrRecord.ndc,
system: "http://hl7.org/fhir/sid/ndc",
display: ehrRecord.drugName,
});
}
Missing expiration
SHLs without expiration live indefinitely. Always set an expiration appropriate to the use case.
Large bundles
IPS bundles with many resources or large PDF attachments can exceed QR code capacity. The SDK handles this by using shlink:/ URLs that reference the manifest server, not inline data. However, very large bundles (>10 MB) may experience slow encryption or upload times. Consider splitting into multiple SHLs if needed.
Next Steps
- Security & Compliance — Zero-knowledge architecture, HIPAA considerations, compliance checklist
- Quick Start — Minimal working example
- Storage Adapters — S3, Azure, GCS, FhirflyStorage comparison
- Server Guide — Self-hosted SHL endpoints