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 Element | FHIR Resource | FHIR Field | Coding System |
|---|---|---|---|
| Test order ID | ServiceRequest | identifier | Local |
| Patient MRN | Patient | identifier | Local (MR) |
| Ordering physician | ServiceRequest | requester | NPI |
| Test type (e.g., Strep A) | DiagnosticReport | code | LOINC |
| Report status | DiagnosticReport | status | FHIR ValueSet |
| Classification result | Observation | valueCodeableConcept | SNOMED CT |
| Confidence score | Observation | valueQuantity | UCUM (%) |
| Spectral SNR | Observation | valueQuantity | Local |
| Abnormal flag | Observation | interpretation | HL7 ObservationInterpretation |
| Specimen type | Specimen | type | SNOMED CT |
| Collection body site | Specimen | collection.bodySite | SNOMED CT |
| Collection time | Specimen | collection.collectedDateTime | ISO 8601 |
| Instrument model | Device | deviceName | Local |
| Instrument serial number | Device | serialNumber | Manufacturer |
| Result timestamp | DiagnosticReport | issued | ISO 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_tokenIn 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.categoryto 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
Deviceresource 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-ididentifier system for patient lookup. - The
Observation.codefield supports broader coding systems. You can use local codes more easily than with Epic. - Oracle Health requires the
Observation.categoryto be present and valid. Uselaboratoryfor 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
| Test | LOINC Code | LOINC Name |
|---|---|---|
| Strep A (throat) | 6558-6 | Streptococcus pyogenes Ag [Presence] in Throat |
| Bacterial identification (culture) | 634-6 | Bacteria identified in specimen by Culture |
| Bacteria identified (non-culture) | 6463-9 | Bacteria identified in specimen |
| Organism identified | 11475-1 | Microorganism identified in specimen |
| Drug identification | 3398-0 | Substance 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
| Result | SNOMED Code | Display |
|---|---|---|
| Positive (detected) | 10828004 | Positive |
| Negative (not detected) | 260385009 | Negative |
| Indeterminate | 419984006 | Inconclusive |
| Streptococcus pyogenes | 80166006 | Streptococcus pyogenes |
| Staphylococcus aureus | 3092008 | Staphylococcus aureus |
| Escherichia coli | 112283007 | Escherichia coli |
SNOMED CT codes for specimens
| Specimen | SNOMED Code |
|---|---|
| Throat swab | 258529004 |
| Nasopharyngeal swab | 258500001 |
| Blood specimen | 119297000 |
| Urine specimen | 122575003 |
| Tissue specimen | 119376003 |
| Sputum | 119334006 |
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.
| Dimension | HL7v2 | FHIR R4 |
|---|---|---|
| Primary use | Instrument → LIS | Application → EHR |
| Transport | TCP/MLLP | HTTPS |
| Auth | Network-level (VPN, firewall) | OAuth 2.0 / SMART |
| Format | Pipe-delimited text | JSON or XML |
| Directionality | Fire-and-forget (with ACK) | Full REST CRUD |
| Hospital adoption | Universal (95%+) | Growing (Epic, Oracle Health mature) |
| Best for | High-volume result delivery to existing LIS | Direct EHR integration, patient data queries |
| Complexity | Lower (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:
- All LOINC codes in
Observation.codeare valid and recognized by the target EHR - All SNOMED CT codes in
valueCodeableConceptresolve correctly - Patient identifiers match the target system's format
- The transaction Bundle is accepted atomically (all-or-nothing)
- Corrected results (
status: "corrected") update the original record - Preliminary results (
status: "preliminary") can be finalized with a subsequent update - The Device resource is recognized or correctly contained
Common FHIR error responses and their causes:
| HTTP Status | OperationOutcome | Likely Cause |
|---|---|---|
| 400 | Invalid resource | Malformed JSON or missing required fields |
| 401 | Unauthorized | Expired or invalid access token |
| 403 | Forbidden | Insufficient scopes in token |
| 404 | Not found | Patient ID does not exist in the system |
| 409 | Conflict | Resource version conflict (concurrent update) |
| 422 | Unprocessable | Valid 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.

