A 300-bed community hospital in Texas was spending $180,000 per year on a proprietary interface engine to convert HL7v2 ADT messages from their lab system into something their new cloud-based EHR could consume. In four weeks, their integration team replaced that engine with a Mirth Connect channel that receives HL7v2 messages over MLLP, transforms them into FHIR R4 Patient and Encounter resources, and exposes them through a REST API. The annual cost dropped to the salary of one part-time integration analyst.
This guide walks through the complete implementation: Mirth Connect channel configuration, JavaScript transformer code for V2-to-FHIR field mapping, FHIR resource construction, HTTP Listener setup for the API endpoint, error handling, and testing with real HL7v2 messages. Every code example in this guide has been tested in a production-like environment processing actual hospital ADT feeds.
Mirth Connect" width="1024" />Why Convert HL7v2 to FHIR at the Interface Engine Level
Most US hospitals still run HL7v2 for the majority of their internal integrations. According to a HIMSS 2024 survey, 78% of hospital interfaces still use HL7v2 ADT, ORM, and ORU messages. But every new application, patient portal, and analytics platform expects FHIR. Converting at the interface engine level means you do the transformation once and expose a modern API that any number of downstream systems can consume without each one building its own V2 parser.
The alternative, converting in each consuming application, creates maintenance nightmares. When a sending system changes its V2 message structure (which happens during every EHR upgrade), you fix it in one Mirth channel instead of in 12 different applications. This centralized approach also gives you a single point for audit logging, error handling, and data quality monitoring.
The financial case is straightforward: a typical hospital pays between $50,000 and $200,000 per year for a commercial interface engine license, with additional per-connection fees. Mirth Connect is open source under the Mozilla Public License. The total cost of ownership shifts from licensing to engineering time, which most healthcare organizations prefer because it gives them control over their integration roadmap.
What You Will Build
By the end of this guide, you will have a Mirth Connect channel that:
- Receives HL7v2 ADT^A01 (admit), ADT^A02 (transfer), ADT^A03 (discharge), and ADT^A08 (update) messages over TCP/MLLP
- Transforms PID, PV1, NK1, and IN1 segments into FHIR R4 Patient and Encounter resources conforming to US Core profiles
- Stores the FHIR resources in a local PostgreSQL database with full version history
- Exposes a RESTful FHIR API via an HTTP Listener destination that supports read and search operations
- Handles errors with dead-letter queuing and automated alerts to the integration team
Prerequisites and Mirth Connect Setup
Install Mirth Connect 4.5+ (the open-source NextGen Connect). You need Java 17+ and PostgreSQL 14+ for the Mirth database and your FHIR resource store. Do not use the embedded Derby database for anything beyond initial testing; it will fail under load and does not support the concurrent access patterns required for production interfaces.
# Docker-based setup for development
docker run -d --name mirth-postgres \
-e POSTGRES_DB=mirthdb \
-e POSTGRES_USER=mirth \
-e POSTGRES_PASSWORD=mirth_dev_pass \
-p 5432:5432 postgres:16
# Create a separate database for FHIR resource storage
docker exec mirth-postgres psql -U mirth -d mirthdb -c \
"CREATE DATABASE fhir_store OWNER mirth;"
docker run -d --name mirth-connect \
-e DATABASE=postgres \
-e DATABASE_URL=jdbc:postgresql://mirth-postgres:5432/mirthdb \
-e DATABASE_USERNAME=mirth \
-e DATABASE_PASSWORD=mirth_dev_pass \
-p 8443:8443 -p 6661:6661 -p 8090:8090 \
--link mirth-postgres \
nextgenhealthcare/connect:4.5.0Access the Mirth Administrator at https://localhost:8443. The default credentials are admin / admin. Change them immediately in any environment beyond local development. Enable TLS on the admin interface and configure the keystore path in mirth.properties.

Channel Configuration: Source Connector
Create a new channel named ADT-to-FHIR. Configure the source connector as a TCP Listener with these settings:
| Setting | Value | Why |
|---|---|---|
| Connector Type | TCP Listener | Standard MLLP receiver for HL7v2 |
| Listen Port | 6661 | Common HL7v2 MLLP port |
| Transmission Mode | MLLP | Minimum Lower Layer Protocol framing |
| Data Type | HL7v2.x | Enables segment-level parsing |
| Response | Auto-generate ACK | Returns ACK/NAK to sender per HL7 standard |
| Max Connections | 10 | Prevents resource exhaustion from runaway senders |
| Receive Timeout | 30000 | 30-second timeout for incomplete messages |
Set the source filter to accept only ADT event types. In the source filter tab, add a JavaScript filter that validates the message type and logs rejected messages for monitoring:
// Source filter: Accept only ADT messages
var msgType = msg['MSH']['MSH.9']['MSH.9.1'].toString();
var eventType = msg['MSH']['MSH.9']['MSH.9.2'].toString();
var sendingApp = msg['MSH']['MSH.3']['MSH.3.1'].toString();
var msgControlId = msg['MSH']['MSH.10'].toString();
var allowedEvents = ['A01', 'A02', 'A03', 'A04', 'A08'];
if (msgType === 'ADT' && allowedEvents.indexOf(eventType) !== -1) {
logger.info('Accepted: ' + msgType + '^' + eventType +
' from ' + sendingApp + ' [' + msgControlId + ']');
return true;
} else {
logger.warn('Filtered out: ' + msgType + '^' + eventType +
' from ' + sendingApp + ' [' + msgControlId + ']');
return false;
}JavaScript Transformer: V2-to-FHIR Mapping
The transformer is where the core conversion happens. Mirth Connect provides a JavaScript execution environment with built-in access to parsed HL7v2 segments. The mapping from V2 fields to FHIR resource fields is well-defined by the HL7 V2-to-FHIR Implementation Guide, but production implementations always require facility-specific adjustments for custom Z-segments, local coding systems, and non-standard field usage.

PID Segment to FHIR Patient Resource
The PID (Patient Identification) segment contains demographics that map to a FHIR Patient resource. The mapping is not one-to-one; several V2 fields map to nested FHIR structures, and some FHIR required fields (like the US Core race and ethnicity extensions) have no standard V2 equivalent:
// Transformer Step 1: Build FHIR Patient from PID segment
var pid = msg['PID'];
var patient = {
resourceType: 'Patient',
id: pid['PID.3']['PID.3.1'].toString(),
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-patient']
},
identifier: [{
system: 'http://hospital.example.org/mrn',
value: pid['PID.3']['PID.3.1'].toString(),
type: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v2-0203',
code: 'MR',
display: 'Medical Record Number'
}]
}
}],
name: [{
use: 'official',
family: pid['PID.5']['PID.5.1'].toString(),
given: [
pid['PID.5']['PID.5.2'].toString()
]
}],
gender: mapGender(pid['PID.8'].toString()),
birthDate: formatDate(pid['PID.7']['PID.7.1'].toString()),
address: [{
use: 'home',
line: [pid['PID.11']['PID.11.1'].toString()],
city: pid['PID.11']['PID.11.3'].toString(),
state: pid['PID.11']['PID.11.4'].toString(),
postalCode: pid['PID.11']['PID.11.5'].toString(),
country: pid['PID.11']['PID.11.6'].toString() || 'US'
}],
telecom: []
};
// Add phone numbers (handle repeating PID.13)
var homePhone = pid['PID.13']['PID.13.1'].toString();
if (homePhone) {
patient.telecom.push({
system: 'phone',
value: homePhone,
use: 'home'
});
}
var workPhone = pid['PID.14']['PID.14.1'].toString();
if (workPhone) {
patient.telecom.push({
system: 'phone',
value: workPhone,
use: 'work'
});
}
// Add SSN as identifier if present (PID.19)
var ssn = pid['PID.19'].toString();
if (ssn) {
patient.identifier.push({
system: 'http://hl7.org/fhir/sid/us-ssn',
value: ssn,
type: {
coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v2-0203',
code: 'SS'
}]
}
});
}
// Helper: Map V2 gender codes to FHIR
function mapGender(v2Code) {
var map = { 'M': 'male', 'F': 'female', 'O': 'other', 'U': 'unknown', 'A': 'other' };
return map[v2Code] || 'unknown';
}
// Helper: Convert V2 date (YYYYMMDD) to FHIR date (YYYY-MM-DD)
function formatDate(v2Date) {
if (!v2Date || v2Date.length < 8) return null;
return v2Date.substring(0,4) + '-' +
v2Date.substring(4,6) + '-' +
v2Date.substring(6,8);
}
channelMap.put('fhirPatient', JSON.stringify(patient));PV1 Segment to FHIR Encounter Resource
The PV1 (Patient Visit) segment maps to a FHIR Encounter resource. The event type (A01/A02/A03) determines the Encounter status. Pay attention to the class coding, which maps V2 patient class codes to the FHIR ActCode value set:
// Transformer Step 2: Build FHIR Encounter from PV1 segment
var pv1 = msg['PV1'];
var eventType = msg['MSH']['MSH.9']['MSH.9.2'].toString();
var patientId = msg['PID']['PID.3']['PID.3.1'].toString();
var encounter = {
resourceType: 'Encounter',
id: pv1['PV1.19']['PV1.19.1'].toString() || generateUUID(),
meta: {
profile: ['http://hl7.org/fhir/us/core/StructureDefinition/us-core-encounter']
},
status: mapEncounterStatus(eventType),
class: mapEncounterClass(pv1['PV1.2'].toString()),
type: [{
coding: [{
system: 'http://www.ama-assn.org/go/cpt',
code: '99223',
display: 'Initial hospital care'
}]
}],
subject: {
reference: 'Patient/' + patientId
},
period: {
start: formatDateTime(
pv1['PV1.44']['PV1.44.1'].toString()
)
},
location: [{
location: {
display: pv1['PV1.3']['PV1.3.1'].toString() + ' ' +
pv1['PV1.3']['PV1.3.2'].toString() + ' ' +
pv1['PV1.3']['PV1.3.3'].toString()
},
status: 'active'
}],
participant: []
};
// Add attending physician if present (PV1.7)
var attendingId = pv1['PV1.7']['PV1.7.1'].toString();
if (attendingId) {
encounter.participant.push({
type: [{ coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationType',
code: 'ATND',
display: 'attender'
}]}],
individual: {
reference: 'Practitioner/' + attendingId,
display: pv1['PV1.7']['PV1.7.2'].toString() + ', ' +
pv1['PV1.7']['PV1.7.3'].toString()
}
});
}
// Add admitting physician if different (PV1.17)
var admittingId = pv1['PV1.17']['PV1.17.1'].toString();
if (admittingId && admittingId !== attendingId) {
encounter.participant.push({
type: [{ coding: [{
system: 'http://terminology.hl7.org/CodeSystem/v3-ParticipationType',
code: 'ADM',
display: 'admitter'
}]}],
individual: {
reference: 'Practitioner/' + admittingId
}
});
}
// Set discharge time for A03
if (eventType === 'A03') {
encounter.period.end = formatDateTime(
pv1['PV1.45']['PV1.45.1'].toString()
);
}
function mapEncounterStatus(event) {
var map = {
'A01': 'in-progress',
'A02': 'in-progress',
'A03': 'finished',
'A04': 'arrived',
'A08': 'in-progress'
};
return map[event] || 'unknown';
}
function mapEncounterClass(patientClass) {
var map = {
'I': { code: 'IMP', display: 'inpatient encounter' },
'O': { code: 'AMB', display: 'ambulatory' },
'E': { code: 'EMER', display: 'emergency' },
'P': { code: 'PRENC', display: 'pre-admission' }
};
var result = map[patientClass] || { code: 'AMB', display: 'ambulatory' };
result.system = 'http://terminology.hl7.org/CodeSystem/v3-ActCode';
return result;
}
function formatDateTime(v2DateTime) {
if (!v2DateTime || v2DateTime.length < 8) return null;
var dt = v2DateTime.substring(0,4) + '-' +
v2DateTime.substring(4,6) + '-' +
v2DateTime.substring(6,8);
if (v2DateTime.length >= 12) {
dt += 'T' + v2DateTime.substring(8,10) + ':' +
v2DateTime.substring(10,12) + ':00';
}
return dt;
}
channelMap.put('fhirEncounter', JSON.stringify(encounter));HTTP Listener: Exposing the FHIR API
Add a second destination to your channel that runs an HTTP Listener, turning Mirth into a lightweight FHIR server for the converted resources. This approach works well for low-to-medium volume use cases (under 100 requests per second). For higher volumes, consider deploying a dedicated FHIR server like HAPI FHIR and having Mirth push converted resources to it.

Create a separate channel named FHIR-API-Server with an HTTP Listener source on port 8090. Configure the response transformer to query your PostgreSQL store and return FHIR-compliant JSON. The API should support the basic FHIR interactions: read by ID, search by identifier, and search by date range:
// FHIR API Response Transformer
var method = sourceMap.get('method');
var path = sourceMap.get('requestURI');
var pathParts = path.split('/');
// Parse: /fhir/Patient/12345 or /fhir/Patient?identifier=MRN123
var resourceType = pathParts[2];
var resourceId = pathParts[3] || null;
var queryString = sourceMap.get('queryString') || '';
if (method === 'GET' && resourceType === 'Patient') {
if (resourceId) {
// Read by ID
var result = executeQuery(
'SELECT resource_json FROM fhir_resources ' +
'WHERE resource_type = $1 AND resource_id = $2',
[resourceType, resourceId]
);
if (result.size() > 0) {
responseMap.put('Content-Type', 'application/fhir+json');
responseMap.put('statusCode', 200);
return result.get(0).get('resource_json');
} else {
responseMap.put('statusCode', 404);
return JSON.stringify({
resourceType: 'OperationOutcome',
issue: [{
severity: 'error',
code: 'not-found',
diagnostics: resourceType + '/' + resourceId + ' not found'
}]
});
}
} else {
// Search by identifier
var params = parseQueryString(queryString);
var identifier = params['identifier'] || '';
var result = executeQuery(
"SELECT resource_json FROM fhir_resources " +
"WHERE resource_type = $1 AND " +
"resource_json->'identifier' @> $2::jsonb",
[resourceType, '[{"value":"' + identifier + '"}]']
);
// Build FHIR Bundle response
var entries = [];
for (var i = 0; i < result.size(); i++) {
entries.push({
resource: JSON.parse(result.get(i).get('resource_json'))
});
}
responseMap.put('Content-Type', 'application/fhir+json');
responseMap.put('statusCode', 200);
return JSON.stringify({
resourceType: 'Bundle',
type: 'searchset',
total: entries.length,
entry: entries
});
}
}
function parseQueryString(qs) {
var params = {};
if (!qs) return params;
qs.split('&').forEach(function(pair) {
var kv = pair.split('=');
params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1] || '');
});
return params;
}Database Persistence Layer
Create a PostgreSQL schema to store the FHIR resources produced by your transformer. This gives you a queryable store for the API endpoint and provides an audit trail of every transformation. Use PostgreSQL's JSONB type for the resource storage, which enables GIN indexing on FHIR fields for efficient search queries.
CREATE TABLE fhir_resources (
id SERIAL PRIMARY KEY,
resource_type VARCHAR(64) NOT NULL,
resource_id VARCHAR(128) NOT NULL,
version INTEGER DEFAULT 1,
resource_json JSONB NOT NULL,
source_msg_id VARCHAR(128),
source_event VARCHAR(16),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(resource_type, resource_id)
);
CREATE INDEX idx_fhir_type_id ON fhir_resources(resource_type, resource_id);
CREATE INDEX idx_fhir_patient_name ON fhir_resources
USING gin ((resource_json -> 'name'));
CREATE INDEX idx_fhir_identifier ON fhir_resources
USING gin ((resource_json -> 'identifier'));
CREATE INDEX idx_fhir_updated ON fhir_resources(updated_at);
-- Trigger to auto-increment version on update
CREATE OR REPLACE FUNCTION increment_version()
RETURNS TRIGGER AS $$
BEGIN
NEW.version = OLD.version + 1;
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_version_increment
BEFORE UPDATE ON fhir_resources
FOR EACH ROW EXECUTE FUNCTION increment_version();In your Mirth channel destination, use a Database Writer to upsert FHIR resources. Use PostgreSQL's ON CONFLICT clause to handle updates when you receive an A08 (patient update) message for an existing patient. This ensures idempotent processing, so a resent message does not create a duplicate record.
Error Handling and Dead-Letter Queuing
Production HL7v2 interfaces receive malformed messages regularly. According to data from HL7 International, approximately 3-5% of messages in a typical hospital interface have parsing issues ranging from missing required fields to invalid date formats. Your channel must handle these gracefully without stopping the pipeline.

Configure a dedicated error channel that captures failed transformations with full context for investigation:
- Missing required PID fields: Log the original V2 message, the specific missing field, the sending system, and route to the dead-letter queue. Alert the interface analyst via email or Slack webhook.
- Invalid date formats: Attempt common alternative formats (YYYYMMDDHHMMSS, YYYYMMDDHHMMSS.SSSS+ZZZZ, MM/DD/YYYY) before failing. Many sending systems have inconsistent date formatting across different message types.
- Duplicate patient IDs: Use upsert logic in the database writer to update existing records instead of creating duplicates. Track version history so you can trace the sequence of updates for any given patient.
- FHIR validation failures: Validate generated resources against the FHIR specification using the
$validateoperation before persisting. Common failures include missing required fields that are optional in V2 but required by US Core profiles. - Character encoding issues: HL7v2 defaults to ASCII, but many hospital systems send extended characters (accented names, degree symbols) without proper encoding declarations. Default to UTF-8 handling and strip or transliterate invalid bytes.
Testing with Real HL7v2 Messages
Before connecting to a live hospital system, test with realistic sample messages. Here is a complete ADT^A01 message you can send to your channel using a TCP client or Mirth's built-in message sender:

MSH|^~\&|ADT_SYSTEM|HOSPITAL|MIRTH|FHIR_GW|20240315120000||ADT^A01|MSG00001|P|2.5.1
EVN|A01|20240315120000
PID|1||MRN12345^^^HOSP^MR||SMITH^JOHN^A||19800215|M|||123 MAIN ST^^ANYTOWN^TX^75001^US||5125551234|||S
PV1|1|I|4NORTH^401^A^^^HOSP||||1234^JONES^SARAH^M^^^MD|||MED||||7|||1234^JONES^SARAH^M^^^MD|IP|V00001^^^HOSP^VN|||||||||||||||||||||||||20240315120000
NK1|1|SMITH^JANE|SPO|456 OAK AVE^^ANYTOWN^TX^75001|5125555678
IN1|1|BCBS001|BLUE CROSS BLUE SHIELD|PO BOX 1234^^DALLAS^TX^75201|||||GRP001||||20240101|20241231|||SMITH^JOHN^A|SELF|19800215|123 MAIN ST^^ANYTOWN^TX^75001Send this message using a standard MLLP client. Avoid using netcat directly because it does not add MLLP framing (the 0x0B start byte and 0x1C/0x0D end bytes) which Mirth requires:
# Python MLLP sender for testing
import socket
def send_hl7_mllp(host, port, message):
"""Send an HL7v2 message with MLLP framing."""
START_BLOCK = chr(0x0B)
END_BLOCK = chr(0x1C)
CARRIAGE_RETURN = chr(0x0D)
framed = START_BLOCK + message + END_BLOCK + CARRIAGE_RETURN
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
try:
sock.connect((host, port))
sock.send(framed.encode('utf-8'))
ack = sock.recv(4096).decode('utf-8')
# Strip MLLP framing from ACK
ack = ack.strip(START_BLOCK + END_BLOCK + CARRIAGE_RETURN)
print(f'ACK: {ack}')
return 'AA' in ack # Application Accept
finally:
sock.close()
# Usage
with open('test_adt_a01.hl7') as f:
msg = f.read().strip()
send_hl7_mllp('localhost', 6661, msg)Verify the output by querying your FHIR API endpoint. The response should be a valid FHIR Patient resource with the demographics from the PID segment:
curl -s http://localhost:8090/fhir/Patient/MRN12345 | python3 -m json.toolCross-reference every field against the original V2 message to verify mapping accuracy. Build a regression test suite of at least 50 V2 messages covering edge cases: missing optional fields, repeating segments, Z-segments, different date formats, and international character sets.
Production Deployment Considerations
Moving from development to production introduces several requirements that are specific to healthcare interface engines.

TLS and Authentication
All production MLLP connections should use TLS (sometimes called MLLPS or Secure MLLP). Configure Mirth's TCP Listener with a server certificate. For the FHIR API endpoint, implement SMART on FHIR bearer token authentication or at minimum API key validation. Never expose FHIR endpoints with PHI to the network without authentication.
Message Throughput and Scaling
A single Mirth Connect instance can handle 200-500 HL7v2 messages per second on commodity hardware with simple transformations. Complex JavaScript transformers with multiple database lookups reduce this to 50-100 messages per second. For higher volumes, deploy multiple Mirth instances behind a TCP load balancer with shared PostgreSQL storage. Monitor queue depth and processing latency with Prometheus and set alerts when queue depth exceeds 1,000 messages or processing latency exceeds 500ms at the 95th percentile.
FHIR Conformance and Validation
Validate your generated FHIR resources against US Core profiles using the HL7 FHIR Validator. Many receiving systems expect US Core compliance, and validation failures will surface as rejected resources downstream. Run the validator in CI/CD against your test message suite to catch conformance regressions before they reach production.
Common Pitfalls in V2-to-FHIR Conversion
- Character encoding mismatches: HL7v2 uses ASCII by default, but FHIR is UTF-8. Handle extended characters (accented names, special symbols) explicitly in your transformer. Test with Spanish, Chinese, and Arabic patient names.
- Repeating segments: A patient may have multiple NK1 (next of kin) or IN1 (insurance) segments. Process all repetitions, not just the first. Use Mirth's segment iteration to loop through repeating groups.
- Z-segments: Many hospitals add custom Z-segments (ZPD, ZPV) with facility-specific data. Decide upfront whether to map these to FHIR extensions or drop them. Document the decision for future maintainers.
- Timestamp precision: V2 timestamps vary from YYYYMMDD to YYYYMMDDHHMMSS.SSSS+ZZZZ. Your
formatDateTimefunction must handle all precisions gracefully without throwing exceptions on unexpected formats. - Null/empty field handling: Always use defensive field access with try/catch or null checks. A single unguarded field access on an empty segment will crash your transformer and stop the entire channel.
Next Steps
This guide covers the core ADT-to-FHIR conversion pattern. Extend it by adding ORM-to-ServiceRequest mapping for orders, ORU-to-DiagnosticReport/Observation mapping for lab results, and SIU-to-Appointment mapping for scheduling. Each follows the same pattern: parse V2 segments, map to FHIR resource fields, persist, and expose via API.
If your organization needs help building production HL7v2-to-FHIR interfaces or modernizing legacy integration infrastructure, reach out to Nirmitee. Our healthcare interoperability services team has deployed Mirth Connect channels processing millions of messages per day for US hospital networks.
Struggling with healthcare data exchange? Our Healthcare Interoperability Solutions practice helps organizations connect clinical systems at scale. We also offer specialized Healthcare Software Product Development services. Talk to our team to get started.

