If you're searching for "HL7v2 to FHIR Mirth Connect" at 2am because your bundle keeps failing US Core validation, you're in the right place. This is the field guide we wished we had on our first ONC certification project — a complete production-grade mapping of the 14 most-used HL7 v2.x messages into US Core 6.1.0 FHIR R4 resources, with the exact Mirth Connect JavaScript transformer code that actually passes Inferno and Touchstone.
This guide is written for the engineer who has already built a working HL7 listener but is now stuck on the FHIR side. We assume you understand FHIR resources, pipe-delimited HL7 messages, and the Mirth Connect transformer model. We will not waste your time on what a Bundle is. We will spend our time on the parts that fail in production: identifier systems, must-support flags, profile canonical URLs, code system bindings, terminology gaps, and the half-dozen things Inferno's "Visual Inspection" tests will reject silently.
Why this mapping problem is harder than it looks
HL7 v2.x and FHIR R4 were not designed to map cleanly. v2 is positional, segment-oriented, and codes are local to the sending facility. FHIR is resource-oriented, must-support driven, and bound to public terminologies like LOINC, SNOMED CT, and RxNorm. The US Core 6.1.0 Implementation Guide adds another layer: every resource has a profile with mandatory extensions (race, ethnicity, birth sex), required value-set bindings, and slicing rules that change quarterly.
What this means in practice: a v2.x message that your trading partner sends without issue can produce a FHIR Bundle that fails validation on five different rules at once. The Mirth transformer is where you close that gap. Done wrong, every channel becomes a special case. Done right, you build a single library of mapping functions that compose across every message type.
We've shipped this mapping for hospital systems, RCM platforms, and ONC-certified EHR vendors. The patterns below are the ones that survived production traffic — not the ones that look elegant in slides.
The 14 HL7v2 segments you actually have to handle
Most ADT, ORM, ORU, SIU, and MDM message variations decompose into a small set of segments. If your transformer library cleanly handles these 14, you can compose any of the 50+ message types in the v2.5.1 Standard.
Quick segment-to-resource reference
| HL7v2 Segment | FHIR Resource | US Core 6.1.0 Profile |
|---|---|---|
| MSH | MessageHeader, Bundle.meta | — |
| EVN | Provenance | us-core-provenance |
| PID | Patient | us-core-patient |
| PD1 | Patient (extensions) | us-core-patient |
| NK1 | RelatedPerson | us-core-relatedperson |
| PV1 | Encounter | us-core-encounter |
| PV2 | Encounter (details) | us-core-encounter |
| OBR | ServiceRequest, DiagnosticReport | us-core-servicerequest |
| OBX | Observation | us-core-observation-lab / vital-signs |
| ORC | ServiceRequest (order control) | us-core-servicerequest |
| AL1 | AllergyIntolerance | us-core-allergyintolerance |
| DG1 | Condition | us-core-condition-encounter-diagnosis |
| PR1 | Procedure | us-core-procedure |
| IN1 | Coverage | us-core-coverage |
Memorize this table. Print it. Tape it to the wall. We have rebuilt every transformer we own around the principle of "one mapper per segment, composed into resources, packaged into bundles."
The 14 message types that cover 95% of real traffic
We pulled production logs across six US health system clients. These 14 message types accounted for over 95% of all inbound v2.x traffic:
- ADT^A01 — Admit/Visit Notification (inpatient register)
- ADT^A02 — Transfer a patient
- ADT^A03 — Discharge/end visit
- ADT^A04 — Register a patient (outpatient)
- ADT^A08 — Update patient information
- ADT^A11 — Cancel admit/visit
- ADT^A13 — Cancel discharge
- ADT^A28 — Add person information
- ADT^A31 — Update person information
- ORM^O01 — Order message (lab, radiology, medication)
- ORU^R01 — Unsolicited observation result
- SIU^S12 — Scheduling notification (new appt)
- MDM^T02 — Original document notification
- VXU^V04 — Vaccination record (immunization registries)
Each of these decomposes into the segments listed above. Build your transformer library once at the segment level, and every message type assembles itself from re-usable pieces. This is the difference between a maintainable interface and a sprawling pile of channel-specific JavaScript.
How the transformer pipeline works
In Mirth Connect, every inbound HL7v2 message passes through a Source Transformer (raw v2 to internal model) and one or more Destination Transformers (model to outbound format — in our case, a FHIR Bundle). We separate these intentionally. The Source Transformer does parsing and normalization. The Destination Transformer does FHIR assembly and validation.
The pattern we recommend:
- Source Transformer: Parse v2 segments, extract every field we care about, run validation, drop into
channelMapas a clean JSON object. - Destination Transformer: Read the channel map, build each FHIR resource, assemble the Bundle, serialize. No v2 parsing here — only FHIR work.
- Code Templates: Shared functions (gender mapping, date formatting, identifier builders, profile URLs) live in a Code Template Library and are imported into every channel.
This split lets us run the same FHIR assembler over messages that came from very different v2 dialects (Epic Bridges, Cerner Open Engine, Allscripts, NextGen) without forking the assembler.
Assembling the FHIR transaction Bundle for ADT^A01
Let's walk through the most common case. An ADT^A01 (inpatient admit) maps cleanly to a FHIR transaction Bundle containing six to nine resources: Patient, Encounter, Practitioner, Organization, Coverage, Condition, optional Flag/Provenance/RelatedPerson resources.
Why a transaction Bundle (not batch, not collection)
The FHIR Bundle has four primary types: document, message, transaction, and batch (plus collection and history). For ADT processing, you almost always want transaction. Here is why:
- transaction — all entries succeed or all fail. The receiving FHIR server runs them atomically. This is what you want for a patient admission: if Coverage fails to persist, you don't want a Patient sitting there without insurance information.
- batch — entries are processed independently. Use this when you want partial success (e.g., bulk historical ingest).
- message — wraps a MessageHeader for messaging-style workflows. Use this if your downstream EHR expects a v2-style event semantics.
- document — first entry must be a Composition. Use this for clinical document architecture (CDA replacements, discharge summaries).
- collection — server does nothing with the entries; for transport only.
The PID segment to US Core Patient mapping
The Patient resource is where 80% of validation failures originate. HL7v2 messages from real hospitals never have a clean PID. They have empty PID-10 (race), missing PID-13 (phone), free-text PID-22 (ethnicity), and PID-3 identifiers without a type code.
Here's the transformer code that handles a real PID and produces a US Core 6.1.0 compliant Patient resource:
// PID extraction
var mrn = getField(msg, 'PID', 'PID.3', 'PID.3.1');
var lastName = getField(msg, 'PID', 'PID.5', 'PID.5.1');
var firstName = getField(msg, 'PID', 'PID.5', 'PID.5.2');
var middle = getField(msg, 'PID', 'PID.5', 'PID.5.3');
var dob = getField(msg, 'PID', 'PID.7', 'PID.7.1');
var gender = getField(msg, 'PID', 'PID.8', 'PID.8.1');
var raceCode = getField(msg, 'PID', 'PID.10', 'PID.10.1');
var ethCode = getField(msg, 'PID', 'PID.22', 'PID.22.1');
var street = getField(msg, 'PID', 'PID.11', 'PID.11.1');
var city = getField(msg, 'PID', 'PID.11', 'PID.11.3');
var state = getField(msg, 'PID', 'PID.11', 'PID.11.4');
var zip = getField(msg, 'PID', 'PID.11', 'PID.11.5');
var phone = getField(msg, 'PID', 'PID.13', 'PID.13.1');
// Normalize
var gMap = {'M':'male','F':'female','O':'other','U':'unknown'};
var fGender = gMap[gender] || 'unknown';
var fDob = (dob && dob.length >= 8)
? dob.substring(0,4)+'-'+dob.substring(4,6)+'-'+dob.substring(6,8)
: null;
// US Core race extension (OMB codes)
var raceExt = buildRaceExtension(raceCode);
var ethExt = buildEthnicityExtension(ethCode);
var birthSexExt = buildBirthSexExtension(gender);
var patient = {
resourceType: 'Patient',
id: mrn,
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']
},
extension: [raceExt, ethExt, birthSexExt].filter(function(e){ return e; }),
identifier: [{
use: 'usual',
type: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v2-0203',
code: 'MR',
display: 'Medical Record Number'
}]
},
system: 'http://' + tenantNamespace + '/mrn',
value: mrn
}],
active: true,
name: [{
use: 'official',
family: lastName,
given: middle ? [firstName, middle] : [firstName]
}],
gender: fGender,
birthDate: fDob,
address: street ? [{
use: 'home',
line: [street], city: city, state: state, postalCode: zip, country: 'US'
}] : undefined,
telecom: phone ? [{ system: 'phone', value: phone, use: 'home' }] : undefined
}; The five Patient validation traps
- Identifier.type is required — US Core mandates a type coding on every identifier. Most v2 messages just give you a value. You must add the v2-0203 code (MR for medical record number, SS for SSN, DL for driver's license).
- Identifier.system must be a URL — not an OID, not a free string. Use
http://hl7.org/fhir/sid/us-ssnfor SSN, your own canonical for tenant MRNs,http://hl7.org/fhir/sid/us-medicarefor Medicare HICN. - birthDate format — v2 sends
19850315. FHIR requires1985-03-15. Forget the hyphens and validation fails silently. - us-core-race extension cardinality — must include exactly one
ombCategorysub-extension. Wrong cardinality is the most common Inferno failure. - gender uses lowercase — FHIR's administrative-gender value set is
male,female,other,unknown. Not "M" or "Male".
The PV1 segment to US Core Encounter mapping
PV1 is denser than PID. It encodes class, ward/room/bed, admit type, attending doctor, referring doctor, hospital service code, and visit number — all positional, all easy to get wrong.
var patientClass = getField(msg, 'PV1', 'PV1.2', 'PV1.2.1'); // I/O/E
var ward = getField(msg, 'PV1', 'PV1.3', 'PV1.3.1');
var room = getField(msg, 'PV1', 'PV1.3', 'PV1.3.2');
var bed = getField(msg, 'PV1', 'PV1.3', 'PV1.3.3');
var admitType = getField(msg, 'PV1', 'PV1.4', 'PV1.4.1');
var attId = getField(msg, 'PV1', 'PV1.7', 'PV1.7.1');
var attLast = getField(msg, 'PV1', 'PV1.7', 'PV1.7.2');
var attFirst = getField(msg, 'PV1', 'PV1.7', 'PV1.7.3');
var visitNum = getField(msg, 'PV1', 'PV1.19', 'PV1.19.1');
// v2 class to v3-ActCode
var cMap = {
'I': {code:'IMP', display:'inpatient encounter'},
'O': {code:'AMB', display:'ambulatory'},
'E': {code:'EMER', display:'emergency'},
'P': {code:'PRENC',display:'pre-admission'},
'R': {code:'IMP', display:'recurring patient'},
'B': {code:'OBSENC',display:'observation encounter'}
};
var encClass = cMap[patientClass] || {code:'AMB', display:'ambulatory'};
var encounter = {
resourceType: 'Encounter',
id: 'enc-' + (visitNum || mrn),
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter']
},
status: 'in-progress',
class: {
system: 'http://terminology.hl7.org/CodeSystem/v3-ActCode',
code: encClass.code,
display: encClass.display
},
type: [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/admit-type',
code: admitType
}]
}],
subject: { reference: 'Patient/' + mrn },
participant: attId ? [{
individual: { reference: 'Practitioner/' + attId },
type: [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationType',
code: 'ATND', display: 'attender'
}]
}]
}] : undefined,
location: ward ? [{
location: { display: ward + ' Room ' + room + ' Bed ' + bed },
status: 'active'
}] : undefined,
serviceProvider: { reference: 'Organization/org-' + facility }
}; Notice the class mapping. PV1-2 is the single most-mis-mapped field in HL7 to FHIR work. Most teams just copy the letter. US Core requires the v3-ActCode value set: IMP, AMB, EMER, OBSENC, PRENC, ACUTE, NONAC, SS, VR. Use the lookup, not the raw letter.
The DG1 segment to US Core Condition mapping
DG1 carries diagnosis codes — usually ICD-10-CM in US workflows. US Core requires the Condition to be bound to either ICD-10-CM or SNOMED CT. The best practice is to dual-code: include the ICD-10 code the EHR sent and translate to SNOMED for downstream interoperability.
var dx = getField(msg, 'DG1', 'DG1.3', 'DG1.3.1');
var dxDesc = getField(msg, 'DG1', 'DG1.3', 'DG1.3.2');
var dxSys = getField(msg, 'DG1', 'DG1.3', 'DG1.3.3'); // ICD10 / SCT
var dxType = getField(msg, 'DG1', 'DG1.6', 'DG1.6.1'); // A=admitting, W=working, F=final
var icdSys = 'http://hl7.org/fhir/sid/icd-10-cm';
var sctSys = 'http://snomed.info/sct';
var fDxSys = (dxSys === 'SCT') ? sctSys : icdSys;
var codings = [{ system: fDxSys, code: dx, display: dxDesc }];
var sctMatch = ICD_TO_SNOMED[dx];
if (sctMatch && fDxSys === icdSys) {
codings.push({ system: sctSys, code: sctMatch.code, display: sctMatch.display });
}
var condition = {
resourceType: 'Condition',
id: 'cond-' + mrn + '-' + dx.replace(/\./g,'-'),
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-condition-encounter-diagnosis']
},
clinicalStatus: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/condition-clinical',
code: 'active'
}]
},
verificationStatus: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/condition-ver-status',
code: 'confirmed'
}]
},
category: [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/condition-category',
code: 'encounter-diagnosis',
display: 'Encounter Diagnosis'
}]
}],
code: { coding: codings, text: dxDesc },
subject: { reference: 'Patient/' + mrn },
encounter: { reference: 'Encounter/enc-' + (visitNum || mrn) }
}; Two crucial details. First, category is required by us-core-condition-encounter-diagnosis. If you forget it, the resource fails validation. Second, the SNOMED dual-coding is what makes your data usable downstream. We maintain a curated ICD-10-CM to SNOMED map for the 300 most-common admission diagnoses; for anything else we fall back to a real-time call to the $translate operation on a terminology server (tx.fhir.org is fine for dev).
The OBR + OBX segments for ORU^R01 (lab results)
Lab result messages have a different shape. An ORU^R01 wraps one or more OBR (order/report) segments, each followed by repeating OBX (observation) segments. Each OBX becomes a single Observation resource. The OBR becomes either a ServiceRequest (the order) or a DiagnosticReport (the report grouping the observations), or both.
// For each OBR group
var orderId = getField(obrSeg, 'OBR.2', 'OBR.2.1');
var loincCode = getField(obrSeg, 'OBR.4', 'OBR.4.1');
var loincName = getField(obrSeg, 'OBR.4', 'OBR.4.2');
var observations = [];
for (var i = 0; i < obxSegs.length; i++) {
var obx = obxSegs[i];
var valueType = getField(obx, 'OBX.2', 'OBX.2.1'); // NM, ST, CE
var obsLoinc = getField(obx, 'OBX.3', 'OBX.3.1');
var obsName = getField(obx, 'OBX.3', 'OBX.3.2');
var obsValue = getField(obx, 'OBX.5', 'OBX.5.1');
var units = getField(obx, 'OBX.6', 'OBX.6.1');
var refRange = getField(obx, 'OBX.7', 'OBX.7.1');
var abnormal = getField(obx, 'OBX.8', 'OBX.8.1');
var status = getField(obx, 'OBX.11', 'OBX.11.1'); // F=final, P=prelim
var statusMap = {'F':'final', 'P':'preliminary', 'C':'corrected', 'X':'cancelled'};
var fStatus = statusMap[status] || 'final';
var obs = {
resourceType: 'Observation',
id: 'obs-' + orderId + '-' + i,
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-observation-lab']
},
status: fStatus,
category: [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/observation-category',
code: 'laboratory'
}]
}],
code: {
coding: [{ system: 'http://loinc.org', code: obsLoinc, display: obsName }]
},
subject: { reference: 'Patient/' + mrn },
encounter: { reference: 'Encounter/enc-' + (visitNum || mrn) },
effectiveDateTime: parseHl7Datetime(getField(obx, 'OBX.14', 'OBX.14.1'))
};
if (valueType === 'NM') {
obs.valueQuantity = {
value: parseFloat(obsValue), unit: units,
system: 'http://unitsofmeasure.org', code: units
};
} else if (valueType === 'CE' || valueType === 'CWE') {
obs.valueCodeableConcept = {
coding: [{ system: 'http://snomed.info/sct', code: obsValue }]
};
} else {
obs.valueString = obsValue;
}
if (abnormal && abnormal !== 'N') {
obs.interpretation = [{
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation',
code: abnormal
}]
}];
}
observations.push(obs);
} Two production lessons. First, OBX.2 (value type) determines which FHIR value[x] you populate. Never assume numeric. Second, the local code in OBX.3 is often a hospital-specific code, not LOINC. You need a local-to-LOINC crosswalk maintained either in Mirth Code Templates or in an external terminology service. Without LOINC, the result fails us-core-observation-lab.
The ORC segment for ORM^O01 (orders) and MedicationRequest
ORC carries order control codes and is shared across order messages. For medication orders (often paired with RXO/RXE/RXR), you build a MedicationRequest instead of a ServiceRequest:
var orderControl = getField(msg, 'ORC', 'ORC.1', 'ORC.1.1'); // NW, OK, CA, DC
var placerOrderNum = getField(msg, 'ORC', 'ORC.2', 'ORC.2.1');
var rxName = getField(msg, 'RXO', 'RXO.1', 'RXO.1.2');
var rxRxNorm = getField(msg, 'RXO', 'RXO.1', 'RXO.1.1');
var dose = getField(msg, 'RXO', 'RXO.2', 'RXO.2.1');
var doseUnit = getField(msg, 'RXO', 'RXO.4', 'RXO.4.1');
var route = getField(msg, 'RXO', 'RXO.6', 'RXO.6.1');
var statusMap = {'NW':'active','OK':'active','CA':'cancelled','DC':'stopped','HD':'on-hold'};
var medReq = {
resourceType: 'MedicationRequest',
id: 'medreq-' + placerOrderNum,
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-medicationrequest']
},
status: statusMap[orderControl] || 'active',
intent: 'order',
medicationCodeableConcept: {
coding: [{
system: 'http://www.nlm.nih.gov/research/umls/rxnorm',
code: rxRxNorm, display: rxName
}]
},
subject: { reference: 'Patient/' + mrn },
encounter: { reference: 'Encounter/enc-' + (visitNum || mrn) },
authoredOn: new Date().toISOString(),
requester: { reference: 'Practitioner/' + attId },
dosageInstruction: [{
route: { coding: [{ system: 'http://snomed.info/sct', code: route }] },
doseAndRate: [{
doseQuantity: { value: parseFloat(dose), unit: doseUnit }
}]
}]
}; US Core's us-core-medicationrequest profile is one of the strictest — status, intent, medication[x], subject, encounter, authoredOn, requester are all must-support. Also note RxNorm is the required code system; do not use NDC unless you have absolutely no RxNorm code available, and even then add an extension explaining why.
AL1 to AllergyIntolerance and IN1 to Coverage
Two short but high-impact segments. AL1 builds AllergyIntolerance, IN1 builds Coverage. Both have US Core profiles you must declare via meta.profile.
// AL1 to AllergyIntolerance
var allergyCode = getField(msg, 'AL1', 'AL1.3', 'AL1.3.1');
var allergyText = getField(msg, 'AL1', 'AL1.3', 'AL1.3.2');
var severity = getField(msg, 'AL1', 'AL1.4', 'AL1.4.1'); // SV, MO, MI
var sevMap = {'SV':'severe','MO':'moderate','MI':'mild'};
var allergy = {
resourceType: 'AllergyIntolerance',
id: 'allergy-' + mrn + '-' + allergyCode,
meta: { profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-allergyintolerance'] },
clinicalStatus: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-clinical',
code: 'active'
}]
},
verificationStatus: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/allergyintolerance-verification',
code: 'confirmed'
}]
},
code: {
coding: [{ system: 'http://snomed.info/sct', code: allergyCode, display: allergyText }],
text: allergyText
},
patient: { reference: 'Patient/' + mrn },
reaction: severity ? [{ severity: sevMap[severity] || 'moderate' }] : undefined
};
// IN1 to Coverage
var insPlanId = getField(msg, 'IN1', 'IN1.3', 'IN1.3.1');
var insName = getField(msg, 'IN1', 'IN1.4', 'IN1.4.1');
var memberId = getField(msg, 'IN1', 'IN1.36', 'IN1.36.1');
var groupNum = getField(msg, 'IN1', 'IN1.8', 'IN1.8.1');
var coverage = {
resourceType: 'Coverage',
id: 'cov-' + mrn,
meta: { profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-coverage'] },
status: 'active',
subscriberId: memberId,
beneficiary: { reference: 'Patient/' + mrn },
payor: [{ display: insName }],
class: [
{ type: { coding:[{code:'group'}] }, value: groupNum, name: insName },
{ type: { coding:[{code:'plan'}] }, value: insPlanId }
]
}; The AllergyIntolerance gotcha: clinicalStatus and verificationStatus are not the same field. You need both, with different code systems. Inferno's "Visual Inspection" will flag you if either is missing.
Building the transaction Bundle
Once each resource is built, wrap them in a transaction Bundle with proper fullUrl values and request blocks. The pattern we use is to assign every resource a urn:uuid: identifier in fullUrl so that intra-bundle references resolve atomically, even before the resources exist on the server:
function uuid() {
// Java UUID — works in Mirth's Rhino runtime
return Packages.java.util.UUID.randomUUID().toString();
}
var patientUrn = 'urn:uuid:' + uuid();
var encounterUrn = 'urn:uuid:' + uuid();
var practUrn = 'urn:uuid:' + uuid();
var orgUrn = 'urn:uuid:' + uuid();
// Rewrite references to use urn:uuid form
encounter.subject = { reference: patientUrn };
encounter.participant[0].individual = { reference: practUrn };
encounter.serviceProvider = { reference: orgUrn };
var bundle = {
resourceType: 'Bundle',
type: 'transaction',
timestamp: new Date().toISOString(),
identifier: {
system: 'urn:ietf:rfc:3986',
value: 'urn:uuid:' + uuid()
},
entry: [
{
fullUrl: patientUrn,
resource: patient,
request: { method: 'PUT', url: 'Patient?identifier=' + encodeURIComponent(patient.identifier[0].system + '|' + mrn) }
},
{
fullUrl: orgUrn,
resource: organization,
request: { method: 'PUT', url: 'Organization?identifier=' + encodeURIComponent('http://hl7.org/fhir/sid/us-npi|' + fac.npi) }
},
{
fullUrl: practUrn,
resource: practitioner,
request: { method: 'PUT', url: 'Practitioner?identifier=' + encodeURIComponent('http://hospital.org/practitioners|' + attId) }
},
{
fullUrl: encounterUrn,
resource: encounter,
request: { method: 'POST', url: 'Encounter' }
}
]
};
channelMap.put('fhirBundle', JSON.stringify(bundle)); Critical detail: use PUT with a conditional URL (?identifier=...) for resources that should be idempotent (Patient, Organization, Practitioner, Coverage). Use POST for resources that should always create new (Encounter, Observation, Provenance). This is the difference between a transformer that creates duplicate patient records every Tuesday and one that correctly upserts.
The 10 US Core validation errors you will hit (and how to fix them)
After running this mapping against ten ONC-certification candidate systems, these are the validation errors we see over and over. Fix them in your Code Templates once and they stop coming back:
- Patient.identifier missing type=MR — Add
identifier.type.codingwith v2-0203 codeMR. Every identifier needs a type. Use SS for SSN, DL for driver's license, PI for patient internal. - Encounter.class is required — Map PV1-2 through the v3-ActCode lookup. Never push raw "I" or "O".
- birthDate format invalid — Convert
YYYYMMDDtoYYYY-MM-DD. Usedob.substring(0,4)+'-'+dob.substring(4,6)+'-'+dob.substring(6,8). - us-core-race extension missing ombCategory — Build the extension with a nested
ombCategorysub-extension referencing CDC race code systemurn:oid:2.16.840.1.113883.6.238. - Observation.code missing LOINC coding — Map OBX-3 local code to LOINC via your crosswalk. Fall back to a tx server
$translatecall for unknown codes. - Condition.clinicalStatus missing — Default to
activefor DG1-derived conditions unless DG1-6 (diagnosis type) is W=working and you wantprovisional. - MedicationRequest.intent missing — Set to
orderfor RXO/RXE;planfor RXC. Never omit. - AllergyIntolerance.clinicalStatus and verificationStatus both required — Default both to
activeandconfirmed. Different code systems — easy to mistake them for one field. - Encounter.subject reference unresolved — Inside a transaction Bundle, reference the
urn:uuid:not the logicalPatient/123. Otherwise the server may resolve it after the transaction commits. - Identifier.system missing or invalid URL — Use
http://hl7.org/fhir/sid/us-npifor NPI,http://hl7.org/fhir/sid/us-ssnfor SSN, and your own canonical URL for tenant MRNs. Never use bare OIDs.
Terminology mapping: LOINC, SNOMED CT, RxNorm, ICD-10-CM
FHIR is bound to public terminologies. v2 is not. You have three options for closing the gap:
- Static crosswalks in Code Templates: For the 100 most common codes (admission diagnoses, common labs, top medications), build a static map in a Mirth Code Template. Lookup is in-process, sub-millisecond. This is what you want for hot paths.
- Real-time terminology service calls: For unknown codes, call
$lookupor$translateon a terminology server. Free options:tx.fhir.org/r4(slow but useful for dev), the NIH VSAC, Snowstorm (SNOMED). Production options: Bioportal, Inferno's terminology backend. - Local terminology database: Run your own Snowstorm or HAPI tx server. This is what most ONC-cert vendors do once volume grows past 100k messages/day.
The pattern in our reference channel: try static map first, then fall back to a cached tx server call, then attach the original code only if both fail. Never silently drop the diagnosis.
Validating with HAPI, Inferno, and Touchstone
Three tools, three layers of validation. You need all three for ONC certification.
HAPI FHIR Validator (structural)
The HAPI Java validator is what you run against every Bundle before sending it. It checks structure, cardinality, datatype, code system bindings. We embed it into our Mirth channel as a Post-Processor:
var validator = Packages.ca.uhn.fhir.context.FhirContext.forR4().newValidator();
var supportChain = new Packages.ca.uhn.fhir.validation.IValidatorModule[]{
new Packages.ca.uhn.fhir.validation.FhirInstanceValidator()
};
var result = validator.validateWithResult(bundle);
if (!result.isSuccessful()) {
for (var i = 0; i < result.getMessages().size(); i++) {
logger.error('Validation: ' + result.getMessages().get(i).getMessage());
}
} This catches the easy 80%. For US Core profile validation, you also need to load the US Core 6.1.0 NPM package into the validator's support chain.
Inferno (ONC FHIR conformance)
Inferno is the ONC reference test harness. The two test kits you care about: Inferno ONC Standardized API Test Kit (g)(10) and Inferno US Core Test Kit. Both run inside Docker. Inferno makes FHIR API calls to your endpoint and verifies the responses conform to US Core 6.1.0. If your transformer pushes valid Bundles into a HAPI FHIR server, Inferno will pass.
One trap: Inferno expects $everything on Patient, SMART on FHIR launch and refresh, and the full Bulk Data Export ($export) operation. These are server-side, not transformer-side, but the transformer must produce data that survives those reads.
Touchstone (test fixtures + automation)
AEGIS Touchstone is the third pillar. It ships test fixtures — pre-built v2 messages and expected FHIR Bundles — that you can run against your transformer to detect regressions. We treat Touchstone fixtures as CI gold: every PR that touches the transformer runs all fixtures, every drift triggers a failure.
Common pitfalls we see in real projects
1. Identifier system URLs
Half the teams we audit are using something like "system": "MRN" instead of a proper URL. FHIR identifiers need a real URL identifier system. For tenant MRNs, pick a canonical URL like https://yourcompany.com/fhir/sid/mrn and never change it. For NPI use http://hl7.org/fhir/sid/us-npi.
2. Bundle.entry.fullUrl missing
If you omit fullUrl on transaction Bundle entries, the server cannot resolve cross-entry references. Always set fullUrl: 'urn:uuid:' + uuid() and use the same URN in any reference field.
3. Encounter.period.start missing
US Core encounters need period.start at minimum. Map from PV1-44 (admit date/time). If empty, fall back to MSH-7 (message timestamp).
4. Practitioner without NPI
For US Core, the Practitioner identifier should include an NPI when one exists. PV1-7 gives you the internal provider ID; you usually need a separate XCN or local lookup to enrich with NPI.
5. Date timezone handling
v2 dates rarely include timezone. FHIR dateTime with a time component requires a timezone. Default to your facility's local zone, not UTC, unless you have a strong reason otherwise.
6. Profile canonical URLs
It's http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient, not http://hl7.org/fhir/us/core/us-core-patient. Use the exact canonical URL or the validator rejects the resource.
7. Repeating segments
DG1, OBX, NK1, IN1, AL1 all repeat. In Mirth's E4X, msg['DG1'] returns a single segment if there's one, or a list if there are many. Always wrap in an array iterator that handles both cases.
For more on broader Mirth integration patterns, see our guide on how to build a robust HL7 interface engine using Mirth Connect, and for the larger landscape, our interoperability standards guide.
Production deployment checklist
- Channel-per-message-family: Don't try to handle all message types in one channel. Group by family (ADT, ORM, ORU, SIU, MDM, VXU) and route on MSH-9.
- Code Templates as your standard library: All shared helpers (getField, parseHl7Datetime, buildIdentifier, build profile meta, terminology lookups) go in Code Templates, never inline.
- Validation in Post-Processor: Run HAPI validation on the assembled bundle before sending. Fail loud, route to a dead-letter channel.
- Idempotent destination: Use conditional
PUT ?identifier=...for upserts. Never blind-create patients. - Dead-letter queue: Any message that fails validation lands in a separate channel for manual review. Don't drop, don't loop.
- Tests as fixtures: Build a fixture directory of v2 messages and expected Bundles. Run them in CI on every commit to the channel.
- Versioned canonicals: Pin your US Core version. Don't ride latest — pin to 6.1.0 (or whatever the regulation requires) and upgrade deliberately.
- Observability: Log message ID, MSH-10, source facility, message type, bundle size, validation result. See our production monitoring guide.
What about the rare message types?
Beyond the 14 above, you will encounter:
- BAR^P01 (billing add): Maps to Account + Claim resources. Niche but important if you ingest financial transactions.
- RDE^O11 (pharmacy encoded order): Similar to ORM but with richer RXE/RXC fields. Maps to MedicationRequest with a full dosing schedule.
- ORM^O01 with RDS (dispense): Maps to MedicationDispense, not MedicationRequest.
- VXU^V04 (vaccination update): Maps to Immunization resource. Required for immunization registry (IIS) submission.
- MFN^M02 (master files notification): Maps to Organization, Practitioner, or Location depending on the master file type. Often used for provider directory sync.
- DFT^P03 (detailed financial transaction): Maps to ChargeItem + Account. Used in revenue-cycle pipelines.
For each of these, the same segment-level mapping library handles 80% of the work. The remaining 20% is message-type-specific glue.
Lead capture: 30-min mapping spike
If you're mid-project and your bundles are failing validation, we can help. Our team has shipped HL7v2 to FHIR transformers for hospital systems, RCM platforms, and ONC-certified vendors. We'll spend 30 minutes on a call, look at three of your real (de-identified) messages, and identify the top three mapping fixes that will get you past Inferno. No slides, no sales pitch — just code.
You can also grab our open-source starter at github.com/nirmitee/mirth-fhir-transformers (MIT licensed): production-grade Code Templates for the 14 segments above, sample channels for ADT^A01 and ORU^R01, and Touchstone fixtures to bootstrap your CI.
Need expert help with HL7 to FHIR conversion? Explore our Healthcare Interoperability Solutions for production-grade Mirth Connect integrations, and our FHIR integration services for end-to-end conformance. Talk to our team to scope a 30-minute mapping spike.
Where to go next
This guide covers the v2-to-FHIR mapping layer. The next problems you will hit:
- Performance at volume: 10,000+ messages/hour requires connection pooling, queue tuning, and validator caching. See our Mirth Connect performance tuning guide.
- High availability: Mirth Connect clusters for zero-downtime failover are non-trivial. See how to set up Mirth Connect for high availability.
- Security and HIPAA: FHIR endpoints carry PHI. Read our Mirth Connect security hardening guide.
- Licensing: Mirth Connect's commercial transition matters for your roadmap. See our commercial transition guide and the alternatives in 2026.
The 14-segment mapping above is the foundation. Everything else — performance, HA, security, monitoring — builds on top of a transformer library that produces valid US Core resources every single time.



