Nirmitee.io
Mirth Connect

HL7 v2 to FHIR Mapping: US Core 6.1.0 Guide with Mirth Connect Transformer Code

July 21, 202616 min readUpdated Oct 19, 2026
Written by
Jitendra Choudhary
Jitendra Choudhary

CTO & Co-Founder

CTO & Co-Founder at Nirmitee.io. Architects healthcare integrations across FHIR, SMART on FHIR, ABDM and NHCX, writing from production experience taking hospital software from sandbox to go-live.

HL7 v2 to FHIR Mapping: US Core 6.1.0 Guide with Mirth Connect Transformer Code

HL7 v2 to FHIR mapping turns each v2 segment into a FHIR R4 resource: PID-3 becomes Patient.identifier, PID-5 Patient.name, PID-7 Patient.birthDate, PV1 an Encounter, OBR a DiagnosticReport and each OBX an Observation, with OBX-2 deciding the value type. The hard parts are translating local codes to LOINC, SNOMED CT and RxNorm, and producing bundles that pass US Core 6.1.0 validation.

New to Mirth? Start with our Mirth Connect guide: what it is, how it works, and when it fits.

This guide maps the 14 segments and 14 message types you meet in almost every project into US Core 6.1.0 resources, with Mirth Connect JavaScript transformer code, mapping tables, business rules, terminology validation and the US Core errors teams hit most. For the wider migration plan (architecture, roadmap, which interfaces stay on v2) see our HL7 v2 to FHIR guide, and to build the streaming pipeline see how to convert HL7 v2 to FHIR with Mirth Connect.

Quick answer: HL7 v2 to FHIR R4 US Core 6.1.0 mapping in Mirth Connect: PID-3 → Patient.identifier, PID-5 → Patient.name, PID-7 → Patient.birthDate, OBR → DiagnosticReport, OBX → Observation (OBX-2 selects value type). The hardest parts are code system translation (local codes → LOINC/SNOMED/RxNorm) and producing US Core-conformant bundles that pass HAPI validator. This guide covers all 14 segment types and 14 message types.

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 to FHIR R4 mapping looks like a field-renaming exercise until you hit the real problems: HL7 v2 is push-based and event-driven; FHIR is resource-based and REST-queried. HL7 v2 encodes data in positional pipe-delimited segments; FHIR uses nested JSON with typed values. Local codes in OBX-3 must become LOINC in Observation.code. Timestamps in HL7 local time must become FHIR UTC offsets. And the output must pass US Core 6.1.0 validation, which has mandatory fields and cardinality rules that HL7 v2 never enforced.

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.

Which HL7 v2 segments do you have to map to FHIR?

The 14 segments you meet in almost every HL7 v2 to FHIR project: MSH (message header → Meta), PID (patient demographics → Patient), PV1 (visit info → Encounter), DG1 (diagnosis → Condition), OBR (order/result header → DiagnosticReport/ServiceRequest), OBX (observation value → Observation), ORC (order control → ServiceRequest), RXE/RXO (pharmacy → MedicationRequest), AL1 (allergy → AllergyIntolerance), IN1 (insurance → Coverage), NK1 (next of kin → RelatedPerson), NTE (notes → Annotation), ZPV/ZPD (custom → Extensions). Master these 14 and the rest are variations.

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 SegmentFHIR ResourceUS Core 6.1.0 Profile
MSHMessageHeader, Bundle.meta,
EVNProvenanceus-core-provenance
PIDPatientus-core-patient
PD1Patient (extensions)us-core-patient
NK1RelatedPersonus-core-relatedperson
PV1Encounterus-core-encounter
PV2Encounter (details)us-core-encounter
OBRServiceRequest, DiagnosticReportus-core-servicerequest
OBXObservationus-core-observation-lab / vital-signs
ORCServiceRequest (order control)us-core-servicerequest
AL1AllergyIntoleranceus-core-allergyintolerance
DG1Conditionus-core-condition-encounter-diagnosis
PR1Procedureus-core-procedure
IN1Coverageus-core-coverage

Memorize this table. Print it. Tape it to the wall. Build around one principle: one mapper per segment, composed into resources, packaged into bundles.

Which HL7 v2 message types matter most for FHIR mapping?

The 14 HL7 v2 message types that carry most hospital traffic: ADT^A01 (admit), ADT^A03 (discharge), ADT^A04 (outpatient register), ADT^A08 (update demographics), ADT^A11 (cancel admit), ADT^A18 (MRN merge), ORM^O01 (order), ORU^R01 (result), SIU^S12 (new appointment), SIU^S13 (reschedule), SIU^S15 (cancel), MDM^T02 (document), DFT^P03 (charge), and BAR^P12 (billing account update). Build Mirth channels for each of these and you handle the vast majority of hospital traffic.

The message types, with their trigger events:

  • 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

The Mirth transformer pipeline for HL7 v2 to FHIR R4: (1) parse the raw HL7 v2 message into a typed segment model, (2) extract segment field values using Mirth's HL7 E4J path notation (e.g. msg['PID']['PID.3']['PID.3.1']), (3) apply code system translation (local codes → LOINC/SNOMED/ICD-10/RxNorm via lookup tables), (4) build the FHIR resource JSON, (5) wrap in a transaction Bundle, (6) POST to the FHIR server destination. Each step is a discrete function in the JavaScript transformer.

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, here, a FHIR Bundle). We separate these intentionally. The Source Transformer does parsing and normalization. The Destination Transformer does FHIR assembly and validation.

The recommended pattern:

  1. Source Transformer: Parse v2 segments, extract every field you need, run validation, drop into channelMap as a clean JSON object.
  2. Destination Transformer: Read the channel map, build each FHIR resource, assemble the Bundle, serialize. No v2 parsing here, only FHIR work.
  3. 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

PID to US Core Patient mapping: PID-3 (identifier list) → Patient.identifier (PID-3.4 as system URI, PID-3.1 as value). PID-5 (patient name) → Patient.name (PID-5.1 family, PID-5.2 given). PID-7 (birth date YYYYMMDD) → Patient.birthDate (convert to YYYY-MM-DD). PID-8 (sex M/F) → Patient.gender (male/female/unknown). PID-11 (address) → Patient.address. PID-13 (phone) → Patient.telecom. US Core requires at least one identifier, one name, and gender, validate these three fields before posting.

The Patient resource is where most 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

  1. 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).
  2. Identifier.system must be a URL, not an OID, not a free string. Use http://hl7.org/fhir/sid/us-ssn for SSN, your own canonical for tenant MRNs, http://hl7.org/fhir/sid/us-medicare for Medicare HICN.
  3. birthDate format, v2 sends 19850315. FHIR requires 1985-03-15. Forget the hyphens and validation fails silently.
  4. us-core-race extension cardinality, must include exactly one ombCategory sub-extension. Wrong cardinality is the most common Inferno failure.
  5. 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 to US Core Encounter mapping: PV1-2 (patient class I/O/E) → Encounter.class (IMP/AMB/EMER). PV1-3 (assigned location) → Encounter.location. PV1-7 (attending physician) → Encounter.participant. PV1-19 (visit number) → Encounter.identifier. PV1-44 (admit datetime) → Encounter.period.start. PV1-45 (discharge datetime) → Encounter.period.end. Status mapping: A01/A04 → in-progress; A03 → finished; A11 → cancelled. Link to the Patient resource via Encounter.subject.

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. A practical pattern is a curated ICD-10-CM to SNOMED CT map for your most common admission diagnoses, with a fallback 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)

ORU^R01 maps to a FHIR DiagnosticReport containing Observation resources. OBR → DiagnosticReport: OBR-3 (filler order number) → DiagnosticReport.identifier, OBR-4 (universal service ID, LOINC) → DiagnosticReport.code, OBR-7 (observation datetime) → DiagnosticReport.effectiveDateTime. Each OBX → Observation: OBX-3 (observation ID, LOINC) → Observation.code, OBX-5 (value) → Observation.value[x] using OBX-2 to select type (NM → valueQuantity, ST → valueString, CWE → valueCodeableConcept, ED → valueAttachment). OBX-8 (abnormal flag) → Observation.interpretation.

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. A reliable pattern 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)

The 10 US Core 6.1.0 validation errors seen in every project: (1) Patient.identifier missing, (2) Patient.gender not in bound ValueSet, (3) Observation.status not in required ValueSet, (4) Observation.code missing LOINC system URI, (5) DiagnosticReport.subject reference not absolute URL, (6) Encounter.class not using V3 ActCode system, (7) MedicationRequest.status missing, (8) Condition.clinicalStatus missing or wrong system, (9) Bundle.entry.fullUrl not a valid URI, (10) Extension URL not absolute. All fixable in the transformer, HAPI validator catches every one.

These are the US Core validation errors that come up again and again. Fix them in your Code Templates once and they stop coming back:

  1. Patient.identifier missing type=MR, Add identifier.type.coding with v2-0203 code MR. Every identifier needs a type. Use SS for SSN, DL for driver's license, PI for patient internal.
  2. Encounter.class is required, Map PV1-2 through the v3-ActCode lookup. Never push raw "I" or "O".
  3. birthDate format invalid, Convert YYYYMMDD to YYYY-MM-DD. Use dob.substring(0,4)+'-'+dob.substring(4,6)+'-'+dob.substring(6,8).
  4. us-core-race extension missing ombCategory, Build the extension with a nested ombCategory sub-extension referencing CDC race code system urn:oid:2.16.840.1.113883.6.238.
  5. Observation.code missing LOINC coding, Map OBX-3 local code to LOINC via your crosswalk. Fall back to a tx server $translate call for unknown codes.
  6. Condition.clinicalStatus missing, Default to active for DG1-derived conditions unless DG1-6 (diagnosis type) is W=working and you want provisional.
  7. MedicationRequest.intent missing, Set to order for RXO/RXE; plan for RXC. Never omit.
  8. AllergyIntolerance.clinicalStatus and verificationStatus both required, Default both to active and confirmed. Different code systems, easy to mistake them for one field.
  9. Encounter.subject reference unresolved, Inside a transaction Bundle, reference the urn:uuid: not the logical Patient/123. Otherwise the server may resolve it after the transaction commits.
  10. Identifier.system missing or invalid URL, Use http://hl7.org/fhir/sid/us-npi for NPI, http://hl7.org/fhir/sid/us-ssn for SSN, and your own canonical URL for tenant MRNs. Never use bare OIDs.

Terminology mapping: LOINC, SNOMED CT, RxNorm, ICD-10-CM

Code system translation is the hardest part of HL7 v2 to FHIR R4 mapping. Four systems to handle: local lab codes in OBX-3 → LOINC (build a lookup table from the lab's LOINC mapping file); diagnosis codes in DG1-3 → ICD-10-CM (usually already ICD-10, but verify version); medication codes in RXE-2 → RxNorm (NDC or local codes need mapping); procedure codes → SNOMED CT or CPT. Store all mappings in Mirth code templates or a backing database, not in transformer JavaScript. Unmapped codes must trigger an alert, silent unmapping causes clinical data loss.

FHIR is bound to public terminologies. v2 is not. You have three options for closing the gap:

  1. 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.
  2. Real-time terminology service calls: For unknown codes, call $lookup or $translate on 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.
  3. Local terminology database: Run your own Snowstorm or HAPI tx server. Teams move to this once message volume makes public servers impractical.

A pattern that works: 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. Embed it in the 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 structural errors. 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. Treat Touchstone fixtures as CI gold: every PR that touches the transformer runs all fixtures, every drift triggers a failure.

What are the common HL7 v2 to FHIR mapping pitfalls?

1. Identifier system URLs

A common mistake is 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 building an HL7 interface engine, and for the larger landscape, our interoperability standards guide.

Which mapping tables does an HL7 v2 to FHIR transformer need?

Most transformers need eight lookup tables that turn terse v2 codes into FHIR CodeableConcepts and references. Keep them in a Mirth Code Template or a reference database, not scattered through channel scripts.

Tablev2 sourceFHIR target
FacilityMSH-4 sending facilityOrganization name and NPI identifier (system http://hl7.org/fhir/sid/us-npi)
Ward or servicePV1-3 point of careEncounter.serviceType with a SNOMED CT specialty code
PayerIN1-3 insurance company IDCoverage.payor and plan details
Critical diagnosisDG1-3 code prefixClinical alert or Flag resource
ICD-10-CM to SNOMED CTDG1-3Condition.code with both codings
GenderPID-8Patient.gender (male, female, other, unknown)
Patient class and admit typePV1-2, PV1-4Encounter.class and admission details
Marital statusPID-16Patient.maritalStatus

For the ICD-10-CM to SNOMED CT crosswalk, start from the National Library of Medicine map file rather than building it by hand.

How do you add business rules during the mapping?

Because the transformer already sees the whole message, it can raise FHIR Flag resources and notifications for rules the receiving team defines. Keep rules small, testable and signed off by the clinical owner; the integration layer should flag, not decide care.

Example ruleTrigger in the v2 messageFHIR output
Critical diagnosis alertDG1-3 prefix in an agreed list, such as A41 (sepsis)Flag plus a notification to the named team
Geriatric screeningAge 65 or over and PV1-2 = I (inpatient)Flag for the screening workflow
ICU admissionPV1-3 ward code is an ICUFlag for bed management and pharmacy
Missing insuranceNo IN1 segmentFlag for financial counselling

How do you validate codes against a terminology server from Mirth Connect?

Call the terminology server's $lookup or $translate operation from the transformer, cache the answers, and fall back to a local map. On Mirth Connect 4.5.2 and Java 17, Rhino's use of java.net.URL hits an internal class blocked by Java 17's module system (open issue nextgenhealthcare/connect #6254). The usual workaround is to add Apache HttpClient JARs to Mirth's custom-lib folder, enable custom libraries in the server configuration, and call Packages.org.apache.http from the transformer.

Two more production points: a public server such as tx.fhir.org adds a network round trip to every message, so use a local terminology server (HAPI FHIR, Snowstorm or Ontoserver) or cached lookups at volume; and FHIR operation URLs contain $, which clashes with Mirth's variable syntax, so build them in JavaScript and pass them through channelMap.

Production deployment checklist

  1. 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.
  2. Code Templates as your standard library: All shared helpers (getField, parseHl7Datetime, buildIdentifier, build profile meta, terminology lookups) go in Code Templates, never inline.
  3. Validation in Post-Processor: Run HAPI validation on the assembled bundle before sending. Fail loud, route to a dead-letter channel.
  4. Idempotent destination: Use conditional PUT ?identifier=... for upserts. Never blind-create patients.
  5. Dead-letter queue: Any message that fails validation lands in a separate channel for manual review. Don't drop, don't loop.
  6. Tests as fixtures: Build a fixture directory of v2 messages and expected Bundles. Run them in CI on every commit to the channel.
  7. Versioned canonicals: Pin your US Core version. Don't ride latest, pin to 6.1.0 (or whatever the regulation requires) and upgrade deliberately.
  8. Observability: Log message ID, MSH-10, source facility, message type, bundle size, validation result. See our production monitoring guide.

Idempotency, dead letters and repeating segments

  • Idempotency: use PUT with deterministic resource IDs or conditional updates, so a retransmitted v2 message produces the same server state.
  • Dead-letter channel: route messages that fail transformation to an error channel for review, and return AE in MSA-1 with the reason instead of a silent AA.
  • Repeating segments: iterate over every DG1 and IN1, not just the first, creating one Condition or Coverage per repetition.
  • Mapping tables under version control: export channels and Code Templates to Git so table changes are reviewed like code.

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 does most of the work; what remains is message-type-specific glue.

Need help with HL7 v2 to FHIR mapping?

If your bundles are failing validation mid-project, send us three de-identified messages and we will point out the mapping fixes that matter most. Our team has built 350+ interfaces on Mirth Connect, and our public Mirth Connect cookbook shows the channel patterns we use. We build to HIPAA requirements, sign BAAs and are ISO 27001:2022 certified. Tell us about your mapping or talk to our team.

Frequently asked questions

How do you map an HL7 v2 PID segment to a FHIR Patient?

PID-3 maps to Patient.identifier, using the assigning authority in PID-3.4 to choose a real system URI. PID-5 maps to Patient.name, PID-7 (YYYYMMDD) to Patient.birthDate (YYYY-MM-DD), PID-8 to Patient.gender and PID-11 to Patient.address. US Core requires at least an identifier, a name and a gender.

What is the FHIR equivalent of an HL7 v2 ORU^R01?

A DiagnosticReport that references one or more Observations. OBR becomes the DiagnosticReport, each OBX becomes an Observation, and OBX-2 decides which value[x] type OBX-5 maps to.

What is US Core 6.1.0 and why does it matter for mapping?

US Core 6.1.0 is the FHIR implementation guide aligned with USCDI v3. It sets the required and must-support elements your resources need, so mapped resources must conform to it to be accepted by certified systems and pass Inferno.

How do you translate local codes during HL7 v2 to FHIR mapping?

Keep mapping tables in Mirth Code Templates or a reference database: local lab codes in OBX-3 to LOINC, diagnoses to ICD-10-CM plus SNOMED CT, medications to RxNorm. Fall back to a terminology server, and alert on anything unmapped instead of dropping it.

Why use PUT instead of POST in the FHIR transaction Bundle?

PUT with a client-assigned ID, or a conditional update, makes the Bundle idempotent. When a v2 message is retransmitted, the second write produces the same server state instead of a duplicate resource.

How do you add race and ethnicity for US Core?

US Core carries them as extensions on Patient. Map PID-10 (race) and PID-22 (ethnic group) into the US Core race and ethnicity extensions using the ombCategory and text sub-extensions.

Does Mirth Connect support FHIR R4?

Yes. Mirth Connect has a FHIR connector for sending and receiving FHIR resources over HTTP. The HL7 v2 to FHIR mapping itself is written in the channel's JavaScript transformers.

Where to go next

This guide covers the mapping layer. Every mapped message starts at an HL7 interface. 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.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

How do you map an HL7 v2 PID segment to a FHIR Patient?

PID-3 maps to Patient.identifier, using the assigning authority in PID-3.4 to choose a real system URI. PID-5 maps to Patient.name, PID-7 (YYYYMMDD) to Patient.birthDate (YYYY-MM-DD), PID-8 to Patient.gender and PID-11 to Patient.address. US Core requires at least an identifier, a name and a gender.

What is the FHIR equivalent of an HL7 v2 ORU^R01?

A DiagnosticReport that references one or more Observations. OBR becomes the DiagnosticReport, each OBX becomes an Observation, and OBX-2 decides which value[x] type OBX-5 maps to.

What is US Core 6.1.0 and why does it matter for mapping?

US Core 6.1.0 is the FHIR implementation guide aligned with USCDI v3. It sets the required and must-support elements your resources need, so mapped resources must conform to it to be accepted by certified systems and pass Inferno.

How do you translate local codes during HL7 v2 to FHIR mapping?

Keep mapping tables in Mirth Code Templates or a reference database: local lab codes in OBX-3 to LOINC, diagnoses to ICD-10-CM plus SNOMED CT, medications to RxNorm. Fall back to a terminology server, and alert on anything unmapped instead of dropping it.

Why use PUT instead of POST in the FHIR transaction Bundle?

PUT with a client-assigned ID, or a conditional update, makes the Bundle idempotent. When a v2 message is retransmitted, the second write produces the same server state instead of a duplicate resource.

How do you add race and ethnicity for US Core?

US Core carries them as extensions on Patient. Map PID-10 (race) and PID-22 (ethnic group) into the US Core race and ethnicity extensions using the ombCategory and text sub-extensions.

Does Mirth Connect support FHIR R4?

Yes. Mirth Connect has a FHIR connector for sending and receiving FHIR resources over HTTP. The HL7 v2 to FHIR mapping itself is written in the channel's JavaScript transformers.
Share