Nirmitee.io
Mirth Connect

Mirth Connect + openEHR Integration: HL7v2 to FLAT Composition

July 9, 202620 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.

Mirth Connect + openEHR Integration: HL7v2 to FLAT Composition

This is a problem because the real world does not run on a single standard. Hospitals in Europe, the Middle East, Southeast Asia, and Australasia are adopting openEHR clinical data repositories (EHRbase, Better Platform, Medblocks, Ocean Informatics) while their upstream systems — lab instruments, ADT systems, pharmacy dispensers, radiology modalities — still speak HL7v2. The integration engine sitting between those two worlds is, more often than not, Mirth Connect.

We built this integration from scratch. No plugin. No library. No connector. Just JavaScript in a Mirth transformer, treating the openEHR CDR as a REST API target. This article documents every decision, every code mapping, and every gotcha so you can reproduce it in your own environment.

Who Needs This

  • Hospitals migrating from HL7v2-based EHRs to openEHR CDRs. Your existing systems will not stop sending HL7v2 messages the day you deploy EHRbase. You need a translation layer.
     
  • Dual-standard environments. Some departments run on FHIR, some on openEHR. Mirth is the natural place to fan out to both.
     
  • National health IT programs. Countries mandating openEHR (Norway, Germany, Australia, Brazil, parts of India) while hospitals still run HL7v2-native HIS platforms.
     
  • Integration engineers evaluating openEHR. You know Mirth inside out, but openEHR is unfamiliar territory. This guide bridges that gap.

The Problem: Mirth Has Zero openEHR Support

Mirth Connect (NextGen Connect) has built-in support for HL7v2, HL7v3 (CDA), FHIR R4, DICOM, X12, delimited text, JSON, XML, and raw binary. It can parse HL7v2 messages into an E4X XML tree, generate FHIR resources with its FHIR Writer destination, and even validate CDA documents against Schematron rules.

For openEHR, it has nothing. Specifically:

CapabilityFHIR in MirthopenEHR in Mirth
Native data type parserYes (FHIR R4)No
Template/profile awarenessPartial (FHIR profiles)No
Dedicated connectorFHIR Writer destinationNone
Message validationJSON Schema, FHIR profilesNone
Query language supportFHIR Search paramsNo AQL support
Community channelsDozens on Mirth ExchangeZero

This means every aspect of the openEHR integration must be hand-built:

  • Composition structure — You build the FLAT JSON object key by key in JavaScript.
     
  • Data type mapping — DV_CODED_TEXT, DV_QUANTITY, DV_DATE_TIME are openEHR-specific types. You encode them manually using path suffixes (|code, |value, |terminology).
     
  • Archetype code mapping — HL7v2 codes like M/F for gender, must be mapped to openEHR local archetype codes like at0003/at0004, which vary by archetype.
     
  • Template paths — The flat composition keys follow a hierarchical path structure that mirrors the openEHR template. You construct these strings by hand.
     
  • CDR interaction — EHRbase, Better Platform, and other CDRs expose REST APIs with different quirks. You use Mirth's HTTP Sender like you would for any external REST service.

The upside: once you understand the pattern, openEHR compositions from Mirth are just JSON objects delivered via HTTP POST. There is no magic. There is no black box. There is a JavaScript transformer and an HTTP Sender.

openEHR Concepts for HL7/FHIR Engineers

If you have spent your career in HL7v2 and FHIR, openEHR will feel both familiar and alien. Here is a minimum viable conceptual map.

The Two-Level Modeling Paradigm

HL7v2 and FHIR define the data model and the information structure in the same specification. A FHIR Patient resource is both the schema and the content definition. If you need to capture something the spec does not have a field for, you extend it (FHIR extensions, Z-segments).

openEHR separates these concerns into two layers:

  1. Reference Model (RM): Defines generic data structures — COMPOSITION, SECTION, OBSERVATION, EVALUATION, INSTRUCTION, ACTION, and their data types (DV_TEXT, DV_CODED_TEXT, DV_QUANTITY, DV_DATE_TIME, etc.). This is analogous to the FHIR base resource types, but more abstract.
     
  2. Archetypes and Templates: Archetypes constrain the Reference Model for specific clinical concepts. The archetype openEHR-EHR-EVALUATION.problem_diagnosis.v1 defines how to capture a diagnosis — what fields exist, what coded values are acceptable, and what cardinality rules apply. Templates combine archetypes into usable clinical forms.

Why this matters for Mirth integration: You do not map HL7v2 fields to generic openEHR RM fields. You map them to specific paths within a specific template. The template determines the paths, and the paths are what you write into the FLAT composition keys.

Key Terms, Translated

openEHR TermClosest HL7v2/FHIR EquivalentWhat It Means
EHRPatient compartmentA container for all health records belonging to one patient. Created once per patient in the CDR.
CompositionFHIR Bundle / CDA documentA single clinical document (admission record, lab report, discharge summary). The atomic unit of data entry.
TemplateFHIR StructureDefinitionA concrete clinical form definition built from archetypes. Defines what fields exist and what their paths are.
ArchetypeFHIR resource definitionA reusable clinical concept model (e.g., "blood pressure", "diagnosis"). Referenced by templates.
SectionCDA sectionOrganizational grouping within a composition (e.g., "Admission Details", "Diagnoses").
DV_CODED_TEXTCodeableConceptA coded value with terminology binding. Can reference external terminologies (SNOMED, ICD-10) or local archetype codes (at-codes).
at-codesAnswer list codesLocal codes defined within an archetype (e.g., at0003 = Male, at0004 = Female in a gender archetype).
FLAT format(No equivalent)A flattened key-value representation of a composition. openEHR-specific.

The Composition Formats: CANONICAL, STRUCTURED, and FLAT

openEHR CDRs accept compositions in three formats:

  • CANONICAL (XML/JSON): The full Reference Model serialization. Deeply nested, verbose, and faithful to the RM class hierarchy. For a 42-field admission record, the CANONICAL JSON runs to 400+ lines with nested archetype_node_id, _type, and name objects at every level.
     
  • STRUCTURED: A JSON format that preserves the nesting but strips some RM overhead. Still complex. Better than CANONICAL for human readability, worse than FLAT.
     
  • FLAT: A completely flattened key-value map where each field is a single key (the template path) and a simple value. This is what we will use.

Architecture Overview

The architecture is straightforward:

  1. Source system sends an HL7v2 ADT^A01 (patient admission) message over MLLP.
     
  2. Mirth TCP Listener receives the message on port 6663 and parses it using the built-in HL7v2 parser.
     
  3. JavaScript Transformer extracts fields from PID, PV1, DG1, NK1 segments, applies code mapping tables, formats dates, and builds a FLAT composition JSON object with 42 keys.
     
  4. HTTP Sender POSTs the FLAT JSON to the EHRbase REST API endpoint: POST /ehr/{ehr_id}/composition?templateId=patient_admission.v1&format=FLAT.
     
  5. EHRbase validates the composition against the uploaded template and stores it.

The critical insight: from Mirth's perspective, EHRbase is just another REST API. There is no openEHR-specific intelligence in the integration engine. All the openEHR knowledge lives in the JavaScript transformer code.

FHIR vs openEHR: Approaching the Same Problem Differently in Mirth

If you have already built Mirth channels that produce FHIR output, this comparison will frame the openEHR approach clearly.

AspectFHIR Approach in MirthopenEHR Approach in Mirth
Output formatFHIR Bundle (JSON)openEHR FLAT Composition (JSON)
Data modelBuild resource objects (Patient, Encounter, Condition)Build flat key-value pairs with template paths
Code systemsStandard terminologies (SNOMED, ICD-10, LOINC) with URIsSame external terminologies PLUS local archetype at-codes
ValidationFHIR Server validates resources against profilesCDR validates composition against uploaded template
Patient identityFHIR Patient resource (embedded or referenced)EHR object in CDR (must exist before posting composition)
Multiple resourcesTransaction Bundle with N entriesSingle FLAT composition (one POST)
Lines of code~100 lines for a rich Patient+Encounter+Condition Bundle~150 lines for a 42-field FLAT composition

The structural difference is significant. In FHIR, you build multiple discrete resource objects and bundle them. In openEHR, you build one flat map where every clinical data point is a path-value pair within a single composition. The FLAT format actually makes the JavaScript simpler in some ways — you never deal with nested objects, just string keys and primitive values.

The FLAT Format: Why It Is the Only Practical Choice for Mirth

The FLAT format was designed for exactly this use case: building compositions programmatically without having to construct the deep RM object graph. Each key is a path through the template hierarchy. Each value is a simple type (string, number, boolean).

FLAT Path Syntax

A FLAT path looks like this:

patient_admission/admission_details/admission_event/patient_class|code

Breaking it down:

  • patient_admission — The template ID (root)
  • /admission_details — A SECTION archetype within the template
  • /admission_event — An OBSERVATION, EVALUATION, or other entry archetype
  • /patient_class — A specific ELEMENT within that entry
  • |code — The suffix indicating which attribute of the data type you are setting

FLAT Suffixes for Common Data Types

Data TypeSuffixesExample
DV_TEXT(none)path: "John Smith"
DV_CODED_TEXT|code, |value, |terminologypath|code: "at0003"
DV_DATE_TIME(none) or |valuepath: "2024-01-15T08:30:00Z"
DV_QUANTITY|magnitude, |unitpath|magnitude: 72.5
DV_BOOLEAN(none)path: true
DV_IDENTIFIER|id, |type, |issuerpath|id: "MRN-12345"
DV_COUNT|magnitudepath|magnitude: 3

Context (ctx) Fields

Every FLAT composition requires metadata in ctx/ prefixed keys:

"ctx/language": "en",
"ctx/territory": "US",
"ctx/composer_name": "Mirth Connect Integration Engine",
"ctx/id_namespace": "HOSPITAL",
"ctx/id_scheme": "HOSPITAL",
"ctx/participation_name:0": "Dr. Robert Smith",
"ctx/participation_function:0": "requester",
"ctx/participation_mode:0": "face-to-face communication",
"ctx/participation_id:0": "DOC001"

These are not clinical data — they describe the context of the composition itself (who created it, in what language, with what workflow participation).

Indexed Paths for Repeating Structures

When a template allows multiple instances of a structure (e.g., multiple diagnoses), you use zero-based indexes:

"patient_admission/diagnosis:0/problem_diagnosis|code": "A41.9",
"patient_admission/diagnosis:0/problem_diagnosis|value": "Sepsis",
"patient_admission/diagnosis:1/problem_diagnosis|code": "I10",
"patient_admission/diagnosis:1/problem_diagnosis|value": "Hypertension"

Step-by-Step: Building the openEHR Transformer Channel

Step 1: Create the Channel

In Mirth Connect Administrator:

  1. Channels > New Channel
  2. Name: HL7v2 ADT to openEHR Composition
  3. Source Connector:
    • Type: TCP Listener
    • Mode: MLLP
    • Port: 6663
    • Inbound data type: HL7v2
  4. Destination Connector:
    • Type: HTTP Sender
    • URL: http://ehrbase-host:8080/ehrbase/rest/openehr/v1/ehr/${ehrId}/composition?templateId=patient_admission.v1&format=FLAT
    • Method: POST
    • Content Type: application/json

For initial development and testing, use a Channel Writer destination instead. This lets you see the FLAT composition output in the message browser without needing a running EHRbase instance.

Step 2: Define the Mapping Tables

Before writing the transformer, define the code mapping tables. These translate HL7v2 table values into openEHR archetype local codes.

This is the most critical part of the integration. HL7v2 uses single-character table codes (M, F, I, O, E). FHIR uses URI-based code systems. openEHR uses archetype-local at-codes — short codes like at0003 that are defined within each archetype and mean different things in different archetypes.

You must consult your template to find the correct at-codes. There is no universal mapping table. The codes below are for a hypothetical patient_admission.v1 template.

Step 3: Extract HL7v2 Fields

This is standard Mirth territory. We extract from PID (patient demographics), PV1 (visit information), DG1 (diagnosis), and NK1 (next of kin) segments using the familiar msg['SEGMENT']['SEGMENT.N']['SEGMENT.N.M'] syntax.

Step 4: Build the FLAT Composition

This is where the openEHR-specific work happens. We construct a JavaScript object where every key is a template path and every value is a primitive.

Step 5: Configure the HTTP Sender

Set up the HTTP Sender destination to POST the FLAT JSON to your EHRbase instance. The ehrId must be resolved first (see the EHR creation section below).

Deep Dive: Code Mapping from HL7v2 to openEHR Archetypes

Gender Mapping

HL7v2 PID.8 uses Table 0001 (Administrative Sex): M, F, O, A, U, N.

In FHIR, you would map M to male with system http://hl7.org/fhir/administrative-gender.

In openEHR, you map to the archetype's own local codes. For a typical gender element in an administrative demographics archetype:

var GENDER_MAP = {
    'M': { code: 'at0003', value: 'Male',    terminology: 'local' },
    'F': { code: 'at0004', value: 'Female',  terminology: 'local' },
    'O': { code: 'at0005', value: 'Other',   terminology: 'local' },
    'U': { code: 'at0006', value: 'Unknown', terminology: 'local' }
};

Each DV_CODED_TEXT requires three FLAT keys:

composition["patient_admission/demographics/gender|code"] = "at0003";
composition["patient_admission/demographics/gender|value"] = "Male";
composition["patient_admission/demographics/gender|terminology"] = "local";

Patient Class Mapping

HL7v2 PV1.2 uses Table 0004 (Patient Class): I (Inpatient), O (Outpatient), E (Emergency).

var PATIENT_CLASS_MAP = {
    'I': { code: 'at0010', value: 'Inpatient',  terminology: 'local' },
    'O': { code: 'at0011', value: 'Outpatient', terminology: 'local' },
    'E': { code: 'at0012', value: 'Emergency',  terminology: 'local' }
};

Next-of-Kin Relationship Mapping

HL7v2 NK1.3 uses Table 0063 (Relationship): SPO (Spouse), PAR (Parent), CHD (Child), etc.

var CONTACT_TYPE_MAP = {
    'SPO': { code: 'at0020', value: 'Spouse',            terminology: 'local' },
    'PAR': { code: 'at0021', value: 'Parent',            terminology: 'local' },
    'CHD': { code: 'at0022', value: 'Child',             terminology: 'local' },
    'SIB': { code: 'at0023', value: 'Sibling',           terminology: 'local' },
    'EMC': { code: 'at0024', value: 'Emergency Contact', terminology: 'local' }
};

Diagnosis: External Terminology Instead of Local Codes

For diagnoses, you typically use external terminologies (ICD-10, SNOMED CT) rather than local at-codes. The FLAT format handles this the same way — three keys per coded value — but with a different terminology identifier:

// ICD-10 coded diagnosis
composition["patient_admission/diagnosis:0/problem_diagnosis|code"] = "A41.9";
composition["patient_admission/diagnosis:0/problem_diagnosis|value"] = "Sepsis, unspecified organism";
composition["patient_admission/diagnosis:0/problem_diagnosis|terminology"] = "ICD-10";

// Or SNOMED CT
composition["patient_admission/diagnosis:0/problem_diagnosis|code"] = "91302008";
composition["patient_admission/diagnosis:0/problem_diagnosis|value"] = "Sepsis (disorder)";
composition["patient_admission/diagnosis:0/problem_diagnosis|terminology"] = "SNOMED-CT";

Complete Transformer Code

This is the complete Mirth JavaScript transformer. Copy it into the Source Transformer of your channel.

// =====================================================
// HL7v2 ADT → openEHR FLAT Composition
// Target: EHRbase CDR (or any openEHR REST API)
// Template: patient_admission.v1
// Output: 42-field FLAT composition JSON
// =====================================================

// === CODE MAPPING TABLES ===
// These at-codes are defined in the openEHR template.
// You MUST verify these against your actual template.

var GENDER_MAP = {
    'M': { code: 'at0003', value: 'Male',    terminology: 'local' },
    'F': { code: 'at0004', value: 'Female',  terminology: 'local' },
    'O': { code: 'at0005', value: 'Other',   terminology: 'local' },
    'U': { code: 'at0006', value: 'Unknown', terminology: 'local' }
};

var PATIENT_CLASS_MAP = {
    'I': { code: 'at0010', value: 'Inpatient',  terminology: 'local' },
    'O': { code: 'at0011', value: 'Outpatient', terminology: 'local' },
    'E': { code: 'at0012', value: 'Emergency',  terminology: 'local' }
};

var CONTACT_TYPE_MAP = {
    'SPO': { code: 'at0020', value: 'Spouse',            terminology: 'local' },
    'PAR': { code: 'at0021', value: 'Parent',            terminology: 'local' },
    'CHD': { code: 'at0022', value: 'Child',             terminology: 'local' },
    'SIB': { code: 'at0023', value: 'Sibling',           terminology: 'local' },
    'EMC': { code: 'at0024', value: 'Emergency Contact', terminology: 'local' }
};

// === EXTRACT HL7v2 FIELDS ===

// PID — Patient Identification
var mrn       = msg['PID']['PID.3']['PID.3.1'].toString();
var lastName  = msg['PID']['PID.5']['PID.5.1'].toString();
var firstName = msg['PID']['PID.5']['PID.5.2'].toString();
var dob       = msg['PID']['PID.7']['PID.7.1'].toString();
var gender    = msg['PID']['PID.8']['PID.8.1'].toString();
var street    = msg['PID']['PID.11']['PID.11.1'].toString();
var city      = msg['PID']['PID.11']['PID.11.3'].toString();
var state     = msg['PID']['PID.11']['PID.11.4'].toString();
var zip       = msg['PID']['PID.11']['PID.11.5'].toString();
var phone     = msg['PID']['PID.13']['PID.13.1'].toString();

// PV1 — Patient Visit
var patientClass = msg['PV1']['PV1.2']['PV1.2.1'].toString();
var ward         = msg['PV1']['PV1.3']['PV1.3.1'].toString();
var room         = msg['PV1']['PV1.3']['PV1.3.2'].toString();
var bed          = msg['PV1']['PV1.3']['PV1.3.3'].toString();
var attId        = msg['PV1']['PV1.7']['PV1.7.1'].toString();
var attLast      = msg['PV1']['PV1.7']['PV1.7.2'].toString();
var attFirst     = msg['PV1']['PV1.7']['PV1.7.3'].toString();
var visitNum     = msg['PV1']['PV1.19']['PV1.19.1'].toString();
var admitDt      = msg['PV1']['PV1.44']['PV1.44.1'].toString();

// DG1 — Diagnosis (optional segment)
var dx = '', dxDesc = '', dxSys = '';
try {
    dx     = msg['DG1']['DG1.3']['DG1.3.1'].toString();
    dxDesc = msg['DG1']['DG1.3']['DG1.3.2'].toString();
    dxSys  = msg['DG1']['DG1.3']['DG1.3.3'].toString();
} catch(e) {
    // DG1 segment may not be present in all ADT messages
}

// NK1 — Next of Kin (optional segment)
var nkName = '', nkRelation = '', nkPhone = '';
try {
    nkName     = msg['NK1']['NK1.2']['NK1.2.1'].toString() + ', '
               + msg['NK1']['NK1.2']['NK1.2.2'].toString();
    nkRelation = msg['NK1']['NK1.3']['NK1.3.1'].toString();
    nkPhone    = msg['NK1']['NK1.4']['NK1.4.1'].toString();
} catch(e) {
    // NK1 segment may not be present
}

// MSH — Message Header
var facility    = msg['MSH']['MSH.4']['MSH.4.1'].toString();
var msgDateTime = msg['MSH']['MSH.7']['MSH.7.1'].toString();

// === FORMAT DATES ===
function formatHL7Date(hl7Date) {
    if (!hl7Date || hl7Date.length < 8) return '';
    var y = hl7Date.substring(0, 4);
    var m = hl7Date.substring(4, 6);
    var d = hl7Date.substring(6, 8);
    if (hl7Date.length >= 14) {
        var hh = hl7Date.substring(8, 10);
        var mm = hl7Date.substring(10, 12);
        var ss = hl7Date.substring(12, 14);
        return y + '-' + m + '-' + d + 'T' + hh + ':' + mm + ':' + ss + 'Z';
    }
    return y + '-' + m + '-' + d;
}

var formattedDob     = formatHL7Date(dob);
var formattedAdmitDt = formatHL7Date(admitDt) || new Date().toISOString();
var compositionTime  = formatHL7Date(msgDateTime) || new Date().toISOString();

// === APPLY CODE MAPPINGS ===
var genderMapped  = GENDER_MAP[gender]       || GENDER_MAP['U'];
var classMapped   = PATIENT_CLASS_MAP[patientClass] || PATIENT_CLASS_MAP['O'];
var contactMapped = CONTACT_TYPE_MAP[nkRelation]    || null;

var dxTerminology = 'ICD-10';
if (dxSys === 'SCT' || dxSys === 'SNOMED') {
    dxTerminology = 'SNOMED-CT';
}

// === BUILD FLAT COMPOSITION ===
var composition = {};

// --- Context metadata (ctx/) ---
composition["ctx/language"]               = "en";
composition["ctx/territory"]              = "US";
composition["ctx/composer_name"]          = "Mirth Connect Integration Engine";
composition["ctx/id_namespace"]           = facility;
composition["ctx/id_scheme"]              = facility;
composition["ctx/participation_name:0"]   = attFirst + ' ' + attLast;
composition["ctx/participation_function:0"] = "requester";
composition["ctx/participation_mode:0"]   = "face-to-face communication";
composition["ctx/participation_id:0"]     = attId;
composition["ctx/time"]                   = compositionTime;

// --- Patient Demographics ---
composition["patient_admission/demographics/patient_name"]     = firstName + ' ' + lastName;
composition["patient_admission/demographics/mrn|id"]           = mrn;
composition["patient_admission/demographics/mrn|type"]         = "MRN";
composition["patient_admission/demographics/mrn|issuer"]       = facility;
composition["patient_admission/demographics/date_of_birth"]    = formattedDob;
composition["patient_admission/demographics/gender|code"]      = genderMapped.code;
composition["patient_admission/demographics/gender|value"]     = genderMapped.value;
composition["patient_admission/demographics/gender|terminology"] = genderMapped.terminology;
composition["patient_admission/demographics/address"]          = street + ', ' + city + ', ' + state + ' ' + zip;
composition["patient_admission/demographics/phone"]            = phone;

// --- Admission Details ---
composition["patient_admission/admission_details/admission_event/patient_class|code"] = classMapped.code;
composition["patient_admission/admission_details/admission_event/patient_class|value"] = classMapped.value;
composition["patient_admission/admission_details/admission_event/patient_class|terminology"] = classMapped.terminology;
composition["patient_admission/admission_details/admission_event/admission_datetime"] = formattedAdmitDt;
composition["patient_admission/admission_details/admission_event/ward"] = ward;
composition["patient_admission/admission_details/admission_event/room"] = room;
composition["patient_admission/admission_details/admission_event/bed"] = bed;
composition["patient_admission/admission_details/admission_event/visit_number"] = visitNum;
composition["patient_admission/admission_details/admission_event/attending_provider"] = attFirst + ' ' + attLast;
composition["patient_admission/admission_details/admission_event/attending_provider_id"] = attId;
composition["patient_admission/admission_details/admission_event/facility"] = facility;

// --- Diagnosis (if DG1 segment present) ---
if (dx) {
    composition["patient_admission/diagnosis:0/problem_diagnosis|code"] = dx;
    composition["patient_admission/diagnosis:0/problem_diagnosis|value"] = dxDesc;
    composition["patient_admission/diagnosis:0/problem_diagnosis|terminology"] = dxTerminology;
    composition["patient_admission/diagnosis:0/date_of_onset"] = formattedAdmitDt;
    composition["patient_admission/diagnosis:0/severity"] = "Moderate";
    composition["patient_admission/diagnosis:0/active_status"] = true;
}

// --- Next of Kin / Emergency Contact (if NK1 segment present) ---
if (nkName && contactMapped) {
    composition["patient_admission/contact:0/contact_name"] = nkName;
    composition["patient_admission/contact:0/contact_type|code"] = contactMapped.code;
    composition["patient_admission/contact:0/contact_type|value"] = contactMapped.value;
    composition["patient_admission/contact:0/contact_type|terminology"] = contactMapped.terminology;
    composition["patient_admission/contact:0/contact_phone"] = nkPhone;
}

// === STORE OUTPUT ===
var compositionJson = JSON.stringify(composition, null, 2);
channelMap.put('openehrComposition', compositionJson);
channelMap.put('templateId', 'patient_admission.v1');
channelMap.put('patientMrn', mrn);
channelMap.put('fieldCount', Object.keys(composition).length.toString());

logger.info('openEHR FLAT composition built: '
    + Object.keys(composition).length + ' fields | '
    + firstName + ' ' + lastName + ' | MRN: ' + mrn);

Input/Output Examples

Input: HL7v2 ADT^A01 Message

MSH|^~\&|HIS|MGH|OPENEHR|EHRBASE|20240115083000||ADT^A01|MSG00001|P|2.5
EVN|A01|20240115083000
PID|1||MRN-67890^^^MGH||JOHNSON^ROBERT^^^MR||19580312|M|||45 OAK STREET^^BOSTON^MA^02108||617-555-0199||M
PV1|1|I|ICU^101^A||||DOC001^SMITH^ROBERT^^^DR|||MED||||1|||DOC001^SMITH^ROBERT^^^DR|IP|VN-2024-001|||||||||||||||||||20240115080000
DG1|1||A41.9^Sepsis, unspecified organism^ICD10|||A
NK1|1|JOHNSON^MARY|SPO|617-555-0198
IN1|1|BCBS|BCBS001|Blue Cross Blue Shield||||GRP-789

Output: openEHR FLAT Composition

{
  "ctx/language": "en",
  "ctx/territory": "US",
  "ctx/composer_name": "Mirth Connect Integration Engine",
  "ctx/id_namespace": "MGH",
  "ctx/id_scheme": "MGH",
  "ctx/participation_name:0": "Robert Smith",
  "ctx/participation_function:0": "requester",
  "ctx/participation_mode:0": "face-to-face communication",
  "ctx/participation_id:0": "DOC001",
  "ctx/time": "2024-01-15T08:30:00Z",
  "patient_admission/demographics/patient_name": "Robert Johnson",
  "patient_admission/demographics/mrn|id": "MRN-67890",
  "patient_admission/demographics/mrn|type": "MRN",
  "patient_admission/demographics/mrn|issuer": "MGH",
  "patient_admission/demographics/date_of_birth": "1958-03-12",
  "patient_admission/demographics/gender|code": "at0003",
  "patient_admission/demographics/gender|value": "Male",
  "patient_admission/demographics/gender|terminology": "local",
  "patient_admission/demographics/address": "45 Oak Street, Boston, MA 02108",
  "patient_admission/demographics/phone": "617-555-0199",
  "patient_admission/admission_details/admission_event/patient_class|code": "at0010",
  "patient_admission/admission_details/admission_event/patient_class|value": "Inpatient",
  "patient_admission/admission_details/admission_event/patient_class|terminology": "local",
  "patient_admission/admission_details/admission_event/admission_datetime": "2024-01-15T08:00:00Z",
  "patient_admission/admission_details/admission_event/ward": "ICU",
  "patient_admission/admission_details/admission_event/room": "101",
  "patient_admission/admission_details/admission_event/bed": "A",
  "patient_admission/admission_details/admission_event/visit_number": "VN-2024-001",
  "patient_admission/admission_details/admission_event/attending_provider": "Robert Smith",
  "patient_admission/admission_details/admission_event/attending_provider_id": "DOC001",
  "patient_admission/admission_details/admission_event/facility": "MGH",
  "patient_admission/diagnosis:0/problem_diagnosis|code": "A41.9",
  "patient_admission/diagnosis:0/problem_diagnosis|value": "Sepsis, unspecified organism",
  "patient_admission/diagnosis:0/problem_diagnosis|terminology": "ICD-10",
  "patient_admission/diagnosis:0/date_of_onset": "2024-01-15T08:00:00Z",
  "patient_admission/diagnosis:0/severity": "Moderate",
  "patient_admission/diagnosis:0/active_status": true,
  "patient_admission/contact:0/contact_name": "Johnson, Mary",
  "patient_admission/contact:0/contact_type|code": "at0020",
  "patient_admission/contact:0/contact_type|value": "Spouse",
  "patient_admission/contact:0/contact_type|terminology": "local",
  "patient_admission/contact:0/contact_phone": "617-555-0198"
}

42 fields. Zero nesting. One HTTP POST. Compare that to the CANONICAL format for the same data, which would be 400+ lines of nested JSON.

Connecting to EHRbase: REST API Setup

Running EHRbase

The fastest way to get EHRbase running for development:

docker run -d \
  --name ehrbase \
  -p 8080:8080 \
  -e SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/ehrbase \
  -e SPRING_DATASOURCE_USERNAME=ehrbase \
  -e SPRING_DATASOURCE_PASSWORD=ehrbase \
  -e SECURITY_AUTHTYPE=BASIC \
  -e SECURITY_AUTHUSER=admin \
  -e SECURITY_AUTHPASSWORD=secret \
  --link postgres \
  ehrbase/ehrbase:2.0.0

Or use the official docker-compose.yml from the EHRbase repository.

Upload the Template

Before you can POST compositions, EHRbase must know the template. Upload the Operational Template (OPT) file:

curl -X POST \
  'http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4' \
  -H 'Content-Type: application/XML' \
  -H 'Authorization: Basic YWRtaW46c2VjcmV0' \
  -d @patient_admission.v1.opt

You create templates using tools like Archetype Designer (web-based, by Better) or LinkEHR. Export as OPT 1.4 format for EHRbase compatibility.

Create an EHR for the Patient

Each patient needs an EHR object in the CDR before compositions can be posted. This is typically a one-time operation:

curl -X POST \
  'http://localhost:8080/ehrbase/rest/openehr/v1/ehr' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Basic YWRtaW46c2VjcmV0' \
  -H 'Prefer: return=representation' \
  -d '{
    "ehr_status": {
      "subject": {
        "external_ref": {
          "id": { "_type": "GENERIC_ID", "value": "MRN-67890", "scheme": "MGH" },
          "namespace": "MGH",
          "type": "PERSON"
        }
      },
      "is_modifiable": true,
      "is_queryable": true
    }
  }'

The response includes the ehr_id (a UUID). Store this mapping: MRN-67890 → ehr_id UUID.

POST the Composition

curl -X POST \
  'http://localhost:8080/ehrbase/rest/openehr/v1/ehr/EHRBASE_EHR_UUID/composition?templateId=patient_admission.v1&format=FLAT' \
  -H 'Content-Type: application/json' \
  -H 'Authorization: Basic YWRtaW46c2VjcmV0' \
  -H 'Prefer: return=representation' \
  -d @composition.json

A successful response returns 201 Created with the composition UID in the ETag and Location headers.

Handling EHR Creation and Composition Lifecycle

In production, you need logic to handle the patient-to-EHR mapping. Here are three approaches:

Approach 1: Pre-Create EHRs in Batch

If your patient registry is known, bulk-create EHR objects and maintain a mapping table in Mirth's global channel map or an external database.

// In the transformer, look up ehrId from a mapping table
var ehrId = globalChannelMap.get('ehrMap_' + mrn);
if (!ehrId) {
    // EHR not found — queue for creation or reject
    logger.error('No EHR found for MRN: ' + mrn);
    return; // Skip this message
}
channelMap.put('ehrId', ehrId);

Approach 2: Create-on-First-Encounter

Use a pre-processor channel that checks for EHR existence and creates one if needed before the composition channel runs.

Approach 3: Use the Subject-Based Endpoint

Some CDRs (including newer EHRbase versions) allow querying by subject ID. You can use the subject_id and subject_namespace query parameters to find the EHR without maintaining a separate mapping:

GET /ehr?subject_id=MRN-67890&subject_namespace=MGH

The tradeoff: this adds a round-trip HTTP call per message, which matters at volume.

Limitations and Caveats

This approach works and is production-viable. But you need to understand what it cannot do.

What This Integration Cannot Do

  1. No template validation in Mirth. The CDR validates the composition when you POST it. If a required field is missing or a code is wrong, you will get a 400 error at runtime, not at design time.
     
  2. No archetype awareness. Mirth does not know what archetypes or templates are. The FLAT paths are strings you construct manually. Typos in paths are silent until the CDR rejects the composition.
     
  3. No AQL (Archetype Query Language) support. AQL is the SQL equivalent for querying openEHR data. You cannot construct or execute AQL queries from within Mirth.
     
  4. No TERMINOLOGY_SERVICE binding. openEHR archetypes can bind elements to external terminologies. Mirth does not enforce these bindings.
     
  5. No versioning awareness. openEHR compositions are versioned. If you POST a composition that already exists, you need the preceding_version_uid to create a new version.
     
  6. No automatic FLAT path discovery. You must read the template or use the EHRbase example endpoint to discover valid paths.

Known Issues with Mirth HTTP Sender for REST APIs

From our testing with Mirth Connect 4.5.2 on Java 17:

  • $ in URLs causes variable substitution. If your EHRbase URL contains a $ character, Mirth will try to substitute it as a variable. Build the full URL in a JavaScript variable and reference it.
     
  • Response body handling. The HTTP Sender response transformer can throw NullPointerException on Java 17 when processing certain response bodies. Use try-catch in response transformers.
     
  • Connection pooling. Mirth's HTTP Sender creates new connections per message by default. For high-volume integrations, consider connection pooling configuration.

CDR-Specific Considerations

CDRFLAT Format SupportAPI Quirks
EHRbase (open source)Full support via ?format=FLATRequires template upload before composition POST. Strict validation.
Better PlatformFull support (originated FLAT format)Proprietary auth. Uses FLAT as default format.
MedblocksPartial (check current version)JavaScript/Web-native CDR. May prefer STRUCTURED.
Ocean Health SystemsCheck vendor documentationDifferent REST API structure.

Production Hardening Checklist

Before deploying to production:

  • Error handling: Wrap every field extraction in try-catch. HL7v2 messages from real systems have missing segments, empty fields, and non-standard formatting.
     
  • MRN-to-EHR mapping: Implement and test your patient-to-EHR resolution strategy. This is the most common failure point.
     
  • Template version management: Pin your template version. If the template changes, FLAT paths may change, breaking the transformer.
     
  • Retry logic: Configure the HTTP Sender queue for retries on 5xx errors. EHRbase being temporarily unavailable should not lose messages.
  •  
  • Dead letter queue: Messages that fail validation (400 errors) should route to an error queue for manual review, not retry indefinitely.
     
  • Logging: Log the compositionUid returned by EHRbase for the audit trail. Map it back to the HL7v2 message control ID (MSH.10).
     
  • Date timezone handling: HL7v2 dates may or may not include timezone offsets. openEHR is strict about ISO 8601. Decide on a timezone policy.
     
  • Multiple DG1 segments: The example handles one diagnosis. Real ADT messages can have 10+ DG1 segments. Loop through them using indexed FLAT paths.
     
  • Character encoding: HL7v2 uses ASCII/Latin-1 by default. openEHR compositions are UTF-8. Handle accented names and special symbols.
     
  • Load testing: FLAT composition POSTs to EHRbase take 50-200ms. At 100 messages/second, you need connection pooling and possibly multiple HTTP Sender threads.

Need expert help with healthcare interoperability or openEHR integration? Our team builds production-grade Mirth Connect channels, FHIR pipelines, and openEHR data flows for hospitals and health IT vendors. 

Related guides

This article is part of our complete guide to Mirth Connect: Mirth Connect overview for health systems. Related deep-dives:

Need production-grade Mirth Connect help? See our Mirth Connect integration services.

Ready to scale?

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

Frequently Asked Questions

Can I use this approach with Better Platform instead of EHRbase?

Yes. Better Platform invented the FLAT format. The REST API endpoints differ slightly, but the FLAT composition JSON itself is compatible across both platforms. The main differences are in authentication, base URL structure, and error response format.

How do I discover the correct FLAT paths for my openEHR template?

Use the EHRbase example endpoint: GET /template/{templateId}/example?format=FLAT returns a complete example FLAT composition with all possible paths. You can also export the web template from Archetype Designer and inspect the tree structure, or manually read the Operational Template XML.

Does Mirth Connect have built-in openEHR support?

No. Mirth Connect has zero built-in openEHR support — no parser, no connector, no template awareness, and no community channels. You must build the integration using JavaScript transformers and HTTP Sender destinations, treating the openEHR CDR as a standard REST API target.

What is the openEHR FLAT format and why use it with Mirth?

The FLAT format is a flattened key-value representation of an openEHR composition where each field is a single path-value pair. It is the only practical choice for Mirth because you can build it as a simple JavaScript object without constructing deeply nested RM class hierarchies. A 42-field admission record is just 42 key-value pairs in one JSON object.
Share