SpectraDx
pectraDx
SpectraDxlight to insight
← Back to blog
Integration

FHIR R4 Spectroscopy Integration: Sending Results to EHRs

Map spectroscopy results to FHIR R4 resources — DiagnosticReport, Observation, Specimen, and Device — with working TypeScript and Python code for Epic.

FHIR R4 Spectroscopy Integration: Sending Results to EHRs

Our companion article on HL7v2 covers the protocol that 95% of hospital LIS installations still use for instrument-to-LIS communication. This article covers the other direction - the modern FHIR R4 approach that every EHR vendor now supports for application-to-EHR communication.

The distinction matters. HL7v2 is what your instrument speaks to the lab information system over a TCP/MLLP connection. FHIR R4 is what your application speaks to the EHR over HTTPS. They serve different integration points, and a production spectroscopy platform needs both.

If you are building a greenfield deployment where the hospital has invested in FHIR infrastructure, or you need to write results directly to the patient chart (bypassing the LIS), or you are integrating with a cloud-based EHR that does not support HL7v2 inbound - FHIR R4 is your path. This article walks through the complete resource mapping, working code in both TypeScript and Python, and the EHR-specific implementation details you need to get spectral classification results accepted by Epic, Oracle Health (Cerner), and MEDITECH Expanse.

Why FHIR for spectroscopy results

FHIR (Fast Healthcare Interoperability Resources) R4 is HL7's modern standard for healthcare data exchange. Unlike HL7v2's pipe-delimited text messages, FHIR uses JSON (or XML) over RESTful HTTP. Resources are self-describing, relationships are explicit, and the specification is freely available with detailed implementation guides.

For spectroscopy instrument integration, FHIR offers three advantages over HL7v2:

Richer data modeling. A FHIR DiagnosticReport can carry the classification result, confidence score, specimen metadata, device information, and ordering context as linked resources - each queryable and updatable independently. In HL7v2, everything is packed into a flat message where relationships are implicit.

Standard authentication. FHIR APIs use OAuth 2.0 via the SMART on FHIR framework. You register your application once, get credentials, and authenticate programmatically. No more negotiating VPN tunnels and firewall rules for MLLP connections.

Bidirectional interaction. FHIR is not fire-and-forget. You can query existing patient records, check for active orders, retrieve prior results, and post new results - all through the same API. With HL7v2, you send a message and hope for an ACK.

The trade-off: FHIR adoption for lab result ingestion is still uneven. Epic and Oracle Health have mature FHIR R4 APIs. MEDITECH Expanse supports FHIR but with a narrower scope. Smaller LIS vendors may not support FHIR inbound at all. Plan for both protocols.

The FHIR R4 resource model for spectral results

A spectral classification result maps to five FHIR R4 resources. Here is how they relate:

ServiceRequest          (the test order)
    │
    ▼
DiagnosticReport        (the report wrapper)
    │
    ├── Observation      (classification result: "Positive")
    ├── Observation      (confidence score: 97.3%)
    ├── Observation      (spectral quality: SNR 42.8)
    │
    ├── Specimen         (throat swab, collection time)
    └── Device           (Bruker Alpha II, serial number)
  • ServiceRequest represents the test order - who ordered it, for which patient, what test. Your system may receive this from the EHR (if the order originates there) or create it (if the order originates at the point of care).
  • DiagnosticReport is the top-level container. It carries the overall status (preliminary, final, corrected), links to all observations, and references the ordering provider and performing organization.
  • Observation resources carry the actual data. You will typically create three: one for the coded classification result, one for the numeric confidence score, and optionally one for the spectral quality metric.
  • Specimen describes the physical sample - specimen type, collection time, body site. For spectroscopy, this also encodes the sample preparation method (direct ATR contact, solution cell, KBr pellet).
  • Device identifies the instrument that produced the measurement. This is critical for audit trails and for troubleshooting when results from a specific instrument drift.

Resource mapping table

This table maps spectroscopy data elements to their FHIR R4 locations:

Spectroscopy Data ElementFHIR ResourceFHIR FieldCoding System
Test order IDServiceRequestidentifierLocal
Patient MRNPatientidentifierLocal (MR)
Ordering physicianServiceRequestrequesterNPI
Test type (e.g., Strep A)DiagnosticReportcodeLOINC
Report statusDiagnosticReportstatusFHIR ValueSet
Classification resultObservationvalueCodeableConceptSNOMED CT
Confidence scoreObservationvalueQuantityUCUM (%)
Spectral SNRObservationvalueQuantityLocal
Abnormal flagObservationinterpretationHL7 ObservationInterpretation
Specimen typeSpecimentypeSNOMED CT
Collection body siteSpecimencollection.bodySiteSNOMED CT
Collection timeSpecimencollection.collectedDateTimeISO 8601
Instrument modelDevicedeviceNameLocal
Instrument serial numberDeviceserialNumberManufacturer
Result timestampDiagnosticReportissuedISO 8601

Building the FHIR bundle: TypeScript

In production, you submit all resources as a FHIR transaction Bundle - a single POST that creates or updates every resource atomically. Here is a complete TypeScript implementation:

interface SpectralResult {
  patientId: string;
  orderId: string;
  orderingProviderNpi: string;
  orderingProviderName: string;
  testLoinc: string;
  testName: string;
  specimenType: string;
  specimenTypeCode: string;
  bodySite: string;
  bodySiteCode: string;
  collectedAt: string; // ISO 8601
  resultCode: string;
  resultDisplay: string;
  resultSystem: string;
  isAbnormal: boolean;
  confidence: number;
  confidenceThreshold: number;
  spectralSnr?: number;
  snrThreshold?: number;
  instrumentModel: string;
  instrumentSerial: string;
  performingOrgId: string;
}
 
function buildFhirBundle(result: SpectralResult): object {
  const now = new Date().toISOString();
  const reportId = crypto.randomUUID();
  const classificationObsId = crypto.randomUUID();
  const confidenceObsId = crypto.randomUUID();
  const specimenId = crypto.randomUUID();
  const deviceId = crypto.randomUUID();
 
  const resultStatus =
    result.confidence >= result.confidenceThreshold ? "final" : "preliminary";
 
  const entries: object[] = [];
 
  // DiagnosticReport
  entries.push({
    fullUrl: `urn:uuid:${reportId}`,
    resource: {
      resourceType: "DiagnosticReport",
      status: resultStatus,
      category: [
        {
          coding: [
            {
              system: "http://terminology.hl7.org/CodeSystem/v2-0074",
              code: "MB",
              display: "Microbiology",
            },
          ],
        },
      ],
      code: {
        coding: [
          {
            system: "http://loinc.org",
            code: result.testLoinc,
            display: result.testName,
          },
        ],
      },
      subject: { reference: `Patient/${result.patientId}` },
      effectiveDateTime: result.collectedAt,
      issued: now,
      performer: [
        { reference: `Organization/${result.performingOrgId}` },
      ],
      result: [
        { reference: `urn:uuid:${classificationObsId}` },
        { reference: `urn:uuid:${confidenceObsId}` },
      ],
      specimen: [{ reference: `urn:uuid:${specimenId}` }],
    },
    request: { method: "POST", url: "DiagnosticReport" },
  });
 
  // Observation: classification result
  entries.push({
    fullUrl: `urn:uuid:${classificationObsId}`,
    resource: {
      resourceType: "Observation",
      status: resultStatus,
      category: [
        {
          coding: [
            {
              system:
                "http://terminology.hl7.org/CodeSystem/observation-category",
              code: "laboratory",
              display: "Laboratory",
            },
          ],
        },
      ],
      code: {
        coding: [
          {
            system: "http://loinc.org",
            code: result.testLoinc,
            display: result.testName,
          },
        ],
      },
      subject: { reference: `Patient/${result.patientId}` },
      effectiveDateTime: result.collectedAt,
      issued: now,
      valueCodeableConcept: {
        coding: [
          {
            system: result.resultSystem,
            code: result.resultCode,
            display: result.resultDisplay,
          },
        ],
      },
      interpretation: [
        {
          coding: [
            {
              system:
                "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation",
              code: result.isAbnormal ? "A" : "N",
              display: result.isAbnormal ? "Abnormal" : "Normal",
            },
          ],
        },
      ],
      device: { reference: `urn:uuid:${deviceId}` },
      specimen: { reference: `urn:uuid:${specimenId}` },
    },
    request: { method: "POST", url: "Observation" },
  });
 
  // Observation: confidence score
  entries.push({
    fullUrl: `urn:uuid:${confidenceObsId}`,
    resource: {
      resourceType: "Observation",
      status: resultStatus,
      category: [
        {
          coding: [
            {
              system:
                "http://terminology.hl7.org/CodeSystem/observation-category",
              code: "laboratory",
            },
          ],
        },
      ],
      code: {
        coding: [
          {
            system: "http://loinc.org",
            code: "LP94892-4",
            display: "Confidence score",
          },
        ],
        text: "Classification Confidence",
      },
      subject: { reference: `Patient/${result.patientId}` },
      effectiveDateTime: result.collectedAt,
      valueQuantity: {
        value: result.confidence,
        unit: "%",
        system: "http://unitsofmeasure.org",
        code: "%",
      },
      referenceRange: [
        {
          low: {
            value: result.confidenceThreshold,
            unit: "%",
            system: "http://unitsofmeasure.org",
            code: "%",
          },
        },
      ],
      device: { reference: `urn:uuid:${deviceId}` },
    },
    request: { method: "POST", url: "Observation" },
  });
 
  // Specimen
  entries.push({
    fullUrl: `urn:uuid:${specimenId}`,
    resource: {
      resourceType: "Specimen",
      type: {
        coding: [
          {
            system: "http://snomed.info/sct",
            code: result.specimenTypeCode,
            display: result.specimenType,
          },
        ],
      },
      subject: { reference: `Patient/${result.patientId}` },
      collection: {
        collectedDateTime: result.collectedAt,
        bodySite: {
          coding: [
            {
              system: "http://snomed.info/sct",
              code: result.bodySiteCode,
              display: result.bodySite,
            },
          ],
        },
      },
    },
    request: { method: "POST", url: "Specimen" },
  });
 
  // Device
  entries.push({
    fullUrl: `urn:uuid:${deviceId}`,
    resource: {
      resourceType: "Device",
      deviceName: [
        {
          name: result.instrumentModel,
          type: "model-name",
        },
      ],
      serialNumber: result.instrumentSerial,
      type: {
        coding: [
          {
            system: "http://snomed.info/sct",
            code: "425978002",
            display: "Fourier transform infrared spectrophotometer",
          },
        ],
      },
    },
    request: { method: "POST", url: "Device" },
  });
 
  return {
    resourceType: "Bundle",
    type: "transaction",
    entry: entries,
  };
}

Usage:

const bundle = buildFhirBundle({
  patientId: "mrn001234",
  orderId: "ORD98765",
  orderingProviderNpi: "1234567890",
  orderingProviderName: "Dr. Robert Smith",
  testLoinc: "6558-6",
  testName: "Streptococcus pyogenes Ag [Presence] in Throat",
  specimenType: "Throat swab",
  specimenTypeCode: "258529004",
  bodySite: "Throat structure",
  bodySiteCode: "49928004",
  collectedAt: "2026-05-08T14:25:00Z",
  resultCode: "10828004",
  resultDisplay: "Positive",
  resultSystem: "http://snomed.info/sct",
  isAbnormal: true,
  confidence: 97.3,
  confidenceThreshold: 95.0,
  spectralSnr: 42.8,
  snrThreshold: 20.0,
  instrumentModel: "Bruker Alpha II",
  instrumentSerial: "ALPHA2-2024-001",
  performingOrgId: "spectradx-main-lab",
});

Building the FHIR bundle: Python

The same logic in Python, using the fhir.resources library for validation:

from datetime import datetime, timezone
from uuid import uuid4
import json
 
 
def build_fhir_bundle(
    patient_id: str,
    test_loinc: str,
    test_name: str,
    specimen_type: str,
    specimen_type_code: str,
    body_site: str,
    body_site_code: str,
    collected_at: str,
    result_code: str,
    result_display: str,
    result_system: str,
    is_abnormal: bool,
    confidence: float,
    confidence_threshold: float,
    instrument_model: str,
    instrument_serial: str,
    performing_org_id: str,
    spectral_snr: float = None,
    snr_threshold: float = None,
) -> dict:
    """
    Build a FHIR R4 transaction Bundle containing a DiagnosticReport
    with linked Observations, Specimen, and Device resources.
    """
    now = datetime.now(timezone.utc).isoformat()
    report_id = str(uuid4())
    classification_obs_id = str(uuid4())
    confidence_obs_id = str(uuid4())
    specimen_id = str(uuid4())
    device_id = str(uuid4())
 
    status = "final" if confidence >= confidence_threshold else "preliminary"
 
    entries = []
 
    # DiagnosticReport
    entries.append({
        "fullUrl": f"urn:uuid:{report_id}",
        "resource": {
            "resourceType": "DiagnosticReport",
            "status": status,
            "category": [{
                "coding": [{
                    "system": "http://terminology.hl7.org/CodeSystem/v2-0074",
                    "code": "MB",
                    "display": "Microbiology",
                }]
            }],
            "code": {
                "coding": [{
                    "system": "http://loinc.org",
                    "code": test_loinc,
                    "display": test_name,
                }]
            },
            "subject": {"reference": f"Patient/{patient_id}"},
            "effectiveDateTime": collected_at,
            "issued": now,
            "performer": [
                {"reference": f"Organization/{performing_org_id}"}
            ],
            "result": [
                {"reference": f"urn:uuid:{classification_obs_id}"},
                {"reference": f"urn:uuid:{confidence_obs_id}"},
            ],
            "specimen": [{"reference": f"urn:uuid:{specimen_id}"}],
        },
        "request": {"method": "POST", "url": "DiagnosticReport"},
    })
 
    # Observation: classification result
    entries.append({
        "fullUrl": f"urn:uuid:{classification_obs_id}",
        "resource": {
            "resourceType": "Observation",
            "status": status,
            "category": [{
                "coding": [{
                    "system": "http://terminology.hl7.org/CodeSystem/"
                              "observation-category",
                    "code": "laboratory",
                }]
            }],
            "code": {
                "coding": [{
                    "system": "http://loinc.org",
                    "code": test_loinc,
                    "display": test_name,
                }]
            },
            "subject": {"reference": f"Patient/{patient_id}"},
            "effectiveDateTime": collected_at,
            "issued": now,
            "valueCodeableConcept": {
                "coding": [{
                    "system": result_system,
                    "code": result_code,
                    "display": result_display,
                }]
            },
            "interpretation": [{
                "coding": [{
                    "system": "http://terminology.hl7.org/CodeSystem/"
                              "v3-ObservationInterpretation",
                    "code": "A" if is_abnormal else "N",
                    "display": "Abnormal" if is_abnormal else "Normal",
                }]
            }],
            "device": {"reference": f"urn:uuid:{device_id}"},
            "specimen": {"reference": f"urn:uuid:{specimen_id}"},
        },
        "request": {"method": "POST", "url": "Observation"},
    })
 
    # Observation: confidence score
    entries.append({
        "fullUrl": f"urn:uuid:{confidence_obs_id}",
        "resource": {
            "resourceType": "Observation",
            "status": status,
            "category": [{
                "coding": [{
                    "system": "http://terminology.hl7.org/CodeSystem/"
                              "observation-category",
                    "code": "laboratory",
                }]
            }],
            "code": {
                "text": "Classification Confidence",
            },
            "subject": {"reference": f"Patient/{patient_id}"},
            "effectiveDateTime": collected_at,
            "valueQuantity": {
                "value": confidence,
                "unit": "%",
                "system": "http://unitsofmeasure.org",
                "code": "%",
            },
            "referenceRange": [{
                "low": {
                    "value": confidence_threshold,
                    "unit": "%",
                    "system": "http://unitsofmeasure.org",
                    "code": "%",
                }
            }],
            "device": {"reference": f"urn:uuid:{device_id}"},
        },
        "request": {"method": "POST", "url": "Observation"},
    })
 
    # Specimen
    entries.append({
        "fullUrl": f"urn:uuid:{specimen_id}",
        "resource": {
            "resourceType": "Specimen",
            "type": {
                "coding": [{
                    "system": "http://snomed.info/sct",
                    "code": specimen_type_code,
                    "display": specimen_type,
                }]
            },
            "subject": {"reference": f"Patient/{patient_id}"},
            "collection": {
                "collectedDateTime": collected_at,
                "bodySite": {
                    "coding": [{
                        "system": "http://snomed.info/sct",
                        "code": body_site_code,
                        "display": body_site,
                    }]
                },
            },
        },
        "request": {"method": "POST", "url": "Specimen"},
    })
 
    # Device
    entries.append({
        "fullUrl": f"urn:uuid:{device_id}",
        "resource": {
            "resourceType": "Device",
            "deviceName": [{
                "name": instrument_model,
                "type": "model-name",
            }],
            "serialNumber": instrument_serial,
            "type": {
                "coding": [{
                    "system": "http://snomed.info/sct",
                    "code": "425978002",
                    "display": "Fourier transform infrared spectrophotometer",
                }]
            },
        },
        "request": {"method": "POST", "url": "Device"},
    })
 
    return {
        "resourceType": "Bundle",
        "type": "transaction",
        "entry": entries,
    }
 
 
# Usage
bundle = build_fhir_bundle(
    patient_id="mrn001234",
    test_loinc="6558-6",
    test_name="Streptococcus pyogenes Ag [Presence] in Throat",
    specimen_type="Throat swab",
    specimen_type_code="258529004",
    body_site="Throat structure",
    body_site_code="49928004",
    collected_at="2026-05-08T14:25:00Z",
    result_code="10828004",
    result_display="Positive",
    result_system="http://snomed.info/sct",
    is_abnormal=True,
    confidence=97.3,
    confidence_threshold=95.0,
    instrument_model="Bruker Alpha II",
    instrument_serial="ALPHA2-2024-001",
    performing_org_id="spectradx-main-lab",
    spectral_snr=42.8,
    snr_threshold=20.0,
)
 
print(json.dumps(bundle, indent=2))

SMART on FHIR authentication

Before you can POST a FHIR Bundle to an EHR, you need credentials. FHIR APIs use the SMART on FHIR authorization framework, which is built on OAuth 2.0.

For instrument integration - where your software runs as a backend service without a human in the browser - you use the SMART Backend Services flow (also called the client credentials flow with JWT assertion). No user login, no redirect URI. Your application proves its identity with a signed JWT.

The flow:

1. Register your app with the EHR vendor
   → You get a client_id and upload your public key

2. At runtime, build a signed JWT assertion:
   - iss: your client_id
   - sub: your client_id
   - aud: the EHR's token endpoint
   - exp: current time + 5 minutes
   - jti: unique token ID

3. POST to the token endpoint:
   grant_type=client_credentials
   &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
   &client_assertion=<your signed JWT>
   &scope=system/DiagnosticReport.write system/Observation.write

4. Receive an access token

5. Use the access token in the Authorization header for FHIR API calls

Here is the implementation in Python:

import time
import uuid
import jwt
import requests
from pathlib import Path
 
 
class SmartBackendAuth:
    """SMART on FHIR Backend Services authentication."""
 
    def __init__(
        self,
        client_id: str,
        token_endpoint: str,
        private_key_path: str,
        scopes: list[str],
    ):
        self.client_id = client_id
        self.token_endpoint = token_endpoint
        self.private_key = Path(private_key_path).read_text()
        self.scopes = scopes
        self._access_token = None
        self._token_expires_at = 0
 
    def _build_client_assertion(self) -> str:
        """Build a signed JWT for the client_credentials grant."""
        now = int(time.time())
        claims = {
            "iss": self.client_id,
            "sub": self.client_id,
            "aud": self.token_endpoint,
            "exp": now + 300,  # 5 minutes
            "iat": now,
            "jti": str(uuid.uuid4()),
        }
        return jwt.encode(claims, self.private_key, algorithm="RS384")
 
    def get_access_token(self) -> str:
        """Get a valid access token, refreshing if expired."""
        if self._access_token and time.time() < self._token_expires_at - 30:
            return self._access_token
 
        assertion = self._build_client_assertion()
        response = requests.post(
            self.token_endpoint,
            data={
                "grant_type": "client_credentials",
                "client_assertion_type": (
                    "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
                ),
                "client_assertion": assertion,
                "scope": " ".join(self.scopes),
            },
        )
        response.raise_for_status()
        token_data = response.json()
 
        self._access_token = token_data["access_token"]
        self._token_expires_at = time.time() + token_data.get(
            "expires_in", 300
        )
        return self._access_token

In TypeScript, using the jose library for JWT signing:

import * as jose from "jose";
 
async function getSmartAccessToken(
  clientId: string,
  tokenEndpoint: string,
  privateKeyPem: string,
  scopes: string[]
): Promise<string> {
  const privateKey = await jose.importPKCS8(privateKeyPem, "RS384");
 
  const assertion = await new jose.SignJWT({})
    .setProtectedHeader({ alg: "RS384", typ: "JWT" })
    .setIssuer(clientId)
    .setSubject(clientId)
    .setAudience(tokenEndpoint)
    .setExpirationTime("5m")
    .setJti(crypto.randomUUID())
    .setIssuedAt()
    .sign(privateKey);
 
  const response = await fetch(tokenEndpoint, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_assertion_type:
        "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
      client_assertion: assertion,
      scope: scopes.join(" "),
    }),
  });
 
  const data = await response.json();
  return data.access_token;
}

Submitting the bundle to an EHR

With authentication handled, submitting the FHIR Bundle is a single POST:

def submit_fhir_bundle(
    bundle: dict,
    fhir_base_url: str,
    auth: SmartBackendAuth,
) -> dict:
    """
    Submit a FHIR transaction Bundle to an EHR.
    Returns the response Bundle with created resource IDs.
    """
    token = auth.get_access_token()
 
    response = requests.post(
        fhir_base_url,
        json=bundle,
        headers={
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/fhir+json",
            "Accept": "application/fhir+json",
        },
    )
 
    if response.status_code == 200:
        response_bundle = response.json()
        for entry in response_bundle.get("entry", []):
            status = entry.get("response", {}).get("status", "")
            location = entry.get("response", {}).get("location", "")
            print(f"  {status}: {location}")
        return response_bundle
 
    elif response.status_code == 422:
        # Validation error - the bundle structure is wrong
        error = response.json()
        issues = error.get("issue", [])
        for issue in issues:
            severity = issue.get("severity", "error")
            diag = issue.get("diagnostics", "No details")
            print(f"  {severity}: {diag}")
        raise ValueError(f"FHIR validation failed: {issues}")
 
    else:
        response.raise_for_status()

Usage with Epic:

auth = SmartBackendAuth(
    client_id="your-epic-app-client-id",
    token_endpoint="https://fhir.epic.com/interconnect-fhir-oauth/oauth2/token",
    private_key_path="/path/to/private_key.pem",
    scopes=[
        "system/DiagnosticReport.write",
        "system/Observation.write",
        "system/Specimen.write",
        "system/Device.write",
    ],
)
 
bundle = build_fhir_bundle(
    patient_id="e63wRTbPfr1p8UW81d8Seiw3",
    test_loinc="6558-6",
    test_name="Streptococcus pyogenes Ag [Presence] in Throat",
    # ... remaining parameters
)
 
result = submit_fhir_bundle(
    bundle=bundle,
    fhir_base_url="https://fhir.epic.com/interconnect-fhir-oauth/api/FHIR/R4",
    auth=auth,
)

EHR-specific implementation details

Epic

Epic is the largest EHR vendor in the US, covering roughly 38% of the hospital market. Their FHIR implementation is the most mature, and it is the one you will encounter most often.

Registration. You register your application through the Epic App Orchard (now called the Epic App Market). For backend services, you create a "Backend System" type app and upload your public key (JWKS). Epic assigns you a client ID. The approval process takes 1-3 weeks for production access - start early.

FHIR version. Epic supports both DSTU2 and R4. Always use R4 unless the hospital is on a very old Epic version (2018 or earlier). The R4 endpoint path typically ends in /api/FHIR/R4.

Patient ID format. Epic uses its own internal patient identifiers (FHIR IDs), not MRNs directly. To find a patient by MRN, query the Patient endpoint first:

response = requests.get(
    f"{fhir_base_url}/Patient",
    params={"identifier": f"urn:oid:1.2.840.114350.1.13.0.1.7.5.737384.14|{mrn}"},
    headers={"Authorization": f"Bearer {token}"},
)
patient = response.json()["entry"][0]["resource"]
epic_patient_id = patient["id"]

The urn:oid value in the identifier query is hospital-specific - it is the OID for that hospital's MRN system. You get this from the hospital's Epic integration team during onboarding.

Known quirks:

  • Epic requires DiagnosticReport.category to include the HL7 v2-0074 diagnostic service section code. Omitting it causes silent failures.
  • Epic's FHIR server is strict about LOINC code validity. Using a LOINC code that is not in their mapping table results in a 422 error.
  • The Device resource must already exist in Epic's system, or you must use a contained resource. You cannot create arbitrary Device resources via the FHIR API in most Epic configurations.

Oracle Health (Cerner)

Oracle Health (formerly Cerner) powers the Millennium EHR platform. Their FHIR R4 implementation is solid and generally closer to the base specification than Epic's.

Registration. Register through the Oracle Health Developer Portal (formerly code.cerner.com). Backend service registration follows the same SMART Backend Services pattern as Epic.

FHIR endpoint. The base URL follows the pattern https://<host>/fhir/r4/<tenant-id>. Each hospital has its own tenant ID.

Key differences from Epic:

  • Oracle Health is more permissive with Device resource creation - you can create new Device resources via the FHIR API.
  • Oracle Health uses a millennium-patient-id identifier system for patient lookup.
  • The Observation.code field supports broader coding systems. You can use local codes more easily than with Epic.
  • Oracle Health requires the Observation.category to be present and valid. Use laboratory for all spectroscopy results.
# Oracle Health patient lookup by MRN
response = requests.get(
    f"{fhir_base_url}/Patient",
    params={"identifier": f"urn:oid:2.16.840.1.113883.6.1000|{mrn}"},
    headers={"Authorization": f"Bearer {token}"},
)

MEDITECH Expanse

MEDITECH Expanse has more limited FHIR support compared to Epic and Oracle Health. The FHIR API is focused on read access for patient-facing apps and third-party integrations.

What works:

  • Reading patient data via FHIR R4
  • Querying existing DiagnosticReports and Observations
  • SMART on FHIR authentication (both standalone launch and backend services)

What is limited:

  • Write operations for DiagnosticReport and Observation are supported but may require MEDITECH professional services to configure custom mappings for non-standard result types.
  • Transaction Bundles are supported but with a narrower set of resource types than Epic or Oracle Health.
  • The FHIR API may not be the primary integration path - MEDITECH often prefers HL7v2 for instrument results, with FHIR reserved for application integrations.

Practical recommendation: For MEDITECH sites, use HL7v2 for the primary result flow (see our HL7v2 article) and FHIR for supplementary operations - querying patient context, checking order status, and retrieving prior results.

Handling corrections and amendments

When a result needs to be corrected - after manual pathologist review overrides the spectral classifier, for example - you update the existing resources rather than creating new ones.

def correct_fhir_result(
    report_id: str,
    observation_id: str,
    new_result_code: str,
    new_result_display: str,
    result_system: str,
    fhir_base_url: str,
    auth: SmartBackendAuth,
):
    """Correct a previously submitted FHIR result."""
    token = auth.get_access_token()
    headers = {
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/fhir+json",
    }
 
    # Update the DiagnosticReport status to "corrected"
    requests.patch(
        f"{fhir_base_url}/DiagnosticReport/{report_id}",
        json=[
            {"op": "replace", "path": "/status", "value": "corrected"},
        ],
        headers={
            **headers,
            "Content-Type": "application/json-patch+json",
        },
    )
 
    # Update the Observation with the corrected value
    requests.patch(
        f"{fhir_base_url}/Observation/{observation_id}",
        json=[
            {"op": "replace", "path": "/status", "value": "corrected"},
            {
                "op": "replace",
                "path": "/valueCodeableConcept",
                "value": {
                    "coding": [{
                        "system": result_system,
                        "code": new_result_code,
                        "display": new_result_display,
                    }]
                },
            },
        ],
        headers={
            **headers,
            "Content-Type": "application/json-patch+json",
        },
    )

The corrected result retains the same resource IDs, preserving the audit trail. The EHR displays the correction history, showing the original and corrected values with timestamps.

Coding systems for spectroscopy results

Getting the coding right is the difference between a result that files correctly and one that disappears into the EHR's unmapped-results queue.

LOINC codes for common spectroscopy tests

TestLOINC CodeLOINC Name
Strep A (throat)6558-6Streptococcus pyogenes Ag [Presence] in Throat
Bacterial identification (culture)634-6Bacteria identified in specimen by Culture
Bacteria identified (non-culture)6463-9Bacteria identified in specimen
Organism identified11475-1Microorganism identified in specimen
Drug identification3398-0Substance identified in specimen

For novel spectroscopy tests that do not have established LOINC codes (most of them, in 2026), submit a request to the Regenstrief Institute for a new LOINC code. In the interim, use a local code with a clear text description:

{
  "code": {
    "coding": [{
      "system": "http://your-lab.org/codes",
      "code": "FTIR-BACT-ID-001",
      "display": "FTIR Bacterial Identification"
    }],
    "text": "FTIR-based bacterial identification via spectral classification"
  }
}

SNOMED CT codes for results

ResultSNOMED CodeDisplay
Positive (detected)10828004Positive
Negative (not detected)260385009Negative
Indeterminate419984006Inconclusive
Streptococcus pyogenes80166006Streptococcus pyogenes
Staphylococcus aureus3092008Staphylococcus aureus
Escherichia coli112283007Escherichia coli

SNOMED CT codes for specimens

SpecimenSNOMED Code
Throat swab258529004
Nasopharyngeal swab258500001
Blood specimen119297000
Urine specimen122575003
Tissue specimen119376003
Sputum119334006

FHIR vs. HL7v2: when to use which

This is not an either/or decision. A production spectroscopy platform needs both protocols, serving different integration points.

DimensionHL7v2FHIR R4
Primary useInstrument → LISApplication → EHR
TransportTCP/MLLPHTTPS
AuthNetwork-level (VPN, firewall)OAuth 2.0 / SMART
FormatPipe-delimited textJSON or XML
DirectionalityFire-and-forget (with ACK)Full REST CRUD
Hospital adoptionUniversal (95%+)Growing (Epic, Oracle Health mature)
Best forHigh-volume result delivery to existing LISDirect EHR integration, patient data queries
ComplexityLower (simple messages)Higher (resource model, auth, error handling)

The practical approach: Build HL7v2 ORU^R01 for LIS integration (see our HL7v2 guide). Build FHIR R4 for direct EHR integration, patient lookup, and order management. Your middleware should support both and route based on what the target hospital has configured.

For a complete picture of how these integration protocols fit into the broader spectroscopy diagnostic pipeline - from instrument acquisition through classification to result delivery - see Building Clinical Workflow Software for Spectroscopy-Based Diagnostics. For details on connecting your spectral results to LIMS systems using ASTM, HL7, and FHIR together, see Connecting Spectroscopy Instruments to LIMS.

Testing FHIR integrations

HAPI FHIR Server. The open-source HAPI FHIR test server (https://hapi.fhir.org) accepts FHIR R4 Bundles without authentication. Use it for structural validation during development. POST your Bundle and inspect the response for validation errors.

EHR sandboxes. Both Epic and Oracle Health provide developer sandboxes with test patients and simulated FHIR endpoints:

  • Epic: The Epic Sandbox (available through the Epic App Market developer portal) includes synthetic patient data and supports the full SMART Backend Services flow.
  • Oracle Health: The Oracle Health Developer Sandbox (code.cerner.com) provides a multi-tenant test environment with pre-populated data.

Validation checklist. Before connecting to a production EHR, verify:

  1. All LOINC codes in Observation.code are valid and recognized by the target EHR
  2. All SNOMED CT codes in valueCodeableConcept resolve correctly
  3. Patient identifiers match the target system's format
  4. The transaction Bundle is accepted atomically (all-or-nothing)
  5. Corrected results (status: "corrected") update the original record
  6. Preliminary results (status: "preliminary") can be finalized with a subsequent update
  7. The Device resource is recognized or correctly contained

Common FHIR error responses and their causes:

HTTP StatusOperationOutcomeLikely Cause
400Invalid resourceMalformed JSON or missing required fields
401UnauthorizedExpired or invalid access token
403ForbiddenInsufficient scopes in token
404Not foundPatient ID does not exist in the system
409ConflictResource version conflict (concurrent update)
422UnprocessableValid JSON but fails business rules (bad LOINC code, etc.)

What comes next

FHIR R4 is the foundation for modern EHR integration, but the standard is still evolving for laboratory and diagnostic use cases. FHIR R5 (released 2023) adds genomics resources and improved Observation grouping, but EHR vendor adoption of R5 is minimal as of mid-2026.

For spectroscopy specifically, the biggest gap in FHIR is the lack of a standardized resource for spectral data itself. The Observation resource carries the classification result, but there is no FHIR resource designed to carry a full spectrum (thousands of wavenumber-absorbance pairs). The spectral data formats article covers the format options for storing and exchanging raw spectral data outside the FHIR ecosystem.

The SpectraDx platform generates both HL7v2 ORU^R01 messages and FHIR R4 transaction Bundles from every spectral classification result. The output format is configured per deployment - HL7v2 to the LIS, FHIR to the EHR, or both. See our full solutions overview to learn how SpectraDx handles the integration layer end to end. If you are building spectroscopy-based diagnostics and want to skip the months of integration engineering, get in touch.

SpectraDx builds clinical workflow software for spectroscopy-based diagnostics.

The layer between the spectrometer and the clinician. Instrument control, patient workflow, ML classification, HL7/FHIR output, and billing — in one platform.

Get articles like this in your inbox.

Monthly technical resources for spectroscopy professionals. No marketing fluff.