Dashboard

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:

  1. Extract — Pull clinical data from your EHR (medications, conditions, labs, immunizations)
  2. Map codes — Convert internal codes to standard terminologies (NDC, ICD-10, LOINC, CVX)
  3. Build IPS Bundle — Use the SDK to create a FHIR-compliant IPS document
  4. 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, and display manually. 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