Schedule a demo

Receiving HL7v2 over MLLP

Zato receives HL7v2 messages over MLLP and handles framing, parsing, routing, acknowledgments, deduplication, encoding, tolerance for non-standard senders, and optional REST bridging - all configured from the web admin dashboard under Channels > HL7 > MLLP.

If you are new to HL7v2 in Zato, start with the HL7v2 tutorial for a guided walkthrough. Each tab below covers one use case - start with Receiving messages for the basics, then explore the others as your integration needs grow.

Receiving messages

A clinical system connects over MLLP, Zato receives the message, parses the ER7 payload, and hands a typed HL7Message object to your service.

Creating a channel

Open the web admin dashboard, navigate to Channels > HL7 > MLLP, and click Create a new MLLP channel.

Fill in:

  • Name - a descriptive name, e.g. adt-inbound
  • Active - whether the channel accepts connections (on by default)
  • Service - the Zato service that will process each message
  • HL7 version - the HL7 version for parsing (HL7 v2.x)

Parsing and validation

Two toggles control what your service receives:

  • Parse on input (on by default) - when on, the service receives a parsed HL7Message object with typed segment and field access. When off, the service receives the raw ER7 string.

  • Validate - when on, the parser validates the message structure against the HL7 schema (required segments, cardinality rules, choice groups). Invalid messages are rejected with an AE acknowledgment.

Writing a service

With parsing on, your service receives the message as self.request.input:

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

if 0:
    from zato_hl7v2.base import HL7Message

class ADTHandler(Service):

    def handle(self) -> 'None':

        # .. the MLLP channel already parsed the raw ER7 bytes ..
        msg:'HL7Message' = self.request.input

        # .. access segments and fields by name ..
        patient_name  = msg.pid.patient_name.family_name
        visit_date    = msg.evn.recorded_date_time
        control_id    = msg.msh.message_control_id

        self.logger.info('Received %s: %s on %s', control_id, patient_name, visit_date)

Testing with a client

Send a message from Python:

from zato.common.hl7.mllp.client import HL7MLLPClient

raw = (
    'MSH|^~\\&|WELLNESS_APP|MAIN_FAC|SCHEDULING|MAIN_FAC|'
    '20240315120000||ADT^A04^ADT_A01|MSG00001|P|2.9\r'
    'EVN|A04|20240315120000\r'
    'PID|1||12345^^^HOSP^MR||SMITH^JOHN^A||19800115|M\r'
    'PV1|1|O\r'
)

client = HL7MLLPClient('localhost', 11223)
response = client.send(raw)
print(response)

You will see an AA acknowledgment - the message was accepted and your service processed it.

Try it: Follow the HL7v2 tutorial for a step-by-step walkthrough that goes from first message to routing, REST bridge, and typed field access.

Routing by MSH fields

A single MLLP port can serve multiple channels. Each channel's Routing tab defines which messages it handles based on MSH header fields.

Available routing fields

FieldMSH positionExample values
Sending applicationMSH-3WELLNESS_APP, LAB_SYS
Sending facilityMSH-4MAIN_FAC, EAST_CLINIC
Receiving applicationMSH-5SCHEDULING, HIS
Receiving facilityMSH-6MAIN_FAC
Message typeMSH-9.1ADT, ORM, ORU
Trigger eventMSH-9.2A04, O01, R01
Processing IDMSH-11P (production), T (training), D (debugging)
Version IDMSH-122.5, 2.9

Only filled fields are checked - empty fields act as wildcards. Matching is case-insensitive.

Example: separate ADT and ORM channels

Create two channels on the same port, each routing to a different service:

ADT channel:

  • Name: adt-handler
  • Service: hl7-api.adt-handler
  • Routing > Message type: ADT

ORM channel:

  • Name: orm-handler
  • Service: hl7-api.orm-handler
  • Routing > Message type: ORM

When a message arrives, Zato checks each channel's routing filters against the MSH segment. The first match wins.

Default catch-all channel

Turn on the Default toggle in the Routing tab to create a catch-all channel. It handles any message that does not match another channel's filters. Only one default channel is allowed per MLLP listener.

Note: If no channel matches and there is no default, Zato responds with an AR (Application Reject) acknowledgment.

REST bridge

Some systems send HL7v2 over HTTP instead of MLLP. The REST bridge lets you receive HL7v2 messages over both MLLP and REST using the same channel configuration.

Enabling the REST bridge

In the Main tab, turn on Use REST. Three additional fields appear:

  • URL path - the HTTP endpoint, e.g. /hl7/adt
  • Security - Basic Auth, API key, or other security definition for the REST endpoint
  • REST only - when off (default), messages arrive over both MLLP and REST. When on, the TCP listener is not started and messages arrive only over REST.

Sending a message over REST

curl -X POST \
  -u hl7user:secret123 \
  -H "Content-Type: text/plain" \
  -d 'MSH|^~\&|SENDER|FAC|RECEIVER|FAC|20240315||ADT^A04|MSG001|P|2.9
EVN|A04|20240315
PID|1||12345||SMITH^JOHN' \
  http://localhost:17010/hl7/adt

The same service handles both MLLP and REST messages. The response body contains the ACK.

Note: The REST bridge creates a companion HTTP channel named hl7.rest.<mllp-channel-name>. It is managed automatically - do not edit or delete it directly.

Protocol and framing

MLLP wraps each HL7 message in a frame: a start byte, the ER7 payload, and an end sequence.

<VT> payload <FS><CR>

Where <VT> is 0x0B, <FS> is 0x1C, and <CR> is 0x0D.

Configuration

The Protocol tab lets you adjust wire-level settings:

FieldDefaultDescription
Start sequence0bHex bytes marking the start of a frame
End sequence1c 0dHex bytes marking the end of a frame
Max message size2 MBMessages larger than this are rejected
Max message size unitMBkB or MB
Read buffer size32768TCP read buffer in bytes
Receive timeout250 msSocket read timeout in milliseconds

Tolerant framing

If a sender omits the start byte but the payload begins with MSH, Zato detects this and treats it as a valid frame start. This handles legacy systems that do not fully comply with the MLLP framing specification.

Oversized messages

When a message exceeds the configured maximum size, Zato closes the connection. Increase the limit if you expect large messages (e.g. with embedded PDF or image data in OBX segments).

Acknowledgments

Zato automatically generates HL7 acknowledgments (ACKs) for every received message.

CodeMeaningWhen
AAApplication AcceptService processed the message successfully
AEApplication ErrorService raised an exception, or validation failed
ARApplication RejectNo routing match and no default channel

ACK structure

Every ACK contains:

  • MSH - sender and receiver swapped from the original, fresh timestamp, ACK message type
  • MSA - the ack code (AA, AE, or AR) and the original message's control ID from MSH-10
  • ERR (on AE only) - error details with HL7 table 0357 code

Error visibility

The Return errors toggle controls what the sender sees on failure:

  • On - the AE acknowledgment includes the actual exception text in the ERR segment. Useful during development and testing.
  • Off - the AE acknowledgment contains a generic error message. Use this in production to avoid leaking internal details.

Custom responses

If your service returns a string, Zato sends it as the response instead of generating an ACK. If the service returns nothing (None), Zato generates the standard AA acknowledgment.

Deduplication

Clinical systems often retransmit messages when they do not receive a timely ACK. Deduplication prevents the same message from being processed twice.

How it works

Zato extracts the Message Control ID from MSH-10 of each incoming message. If the same control ID was already seen within the configured time-to-live window, the message is treated as a duplicate:

  • An AA acknowledgment is returned immediately
  • The service is not invoked again

Configuration

Open the Dedup tab:

FieldDefaultDescription
Dedup TTL14How long to remember control IDs
Dedup TTL unitDaysMinutes, Hours, or Days

Set the TTL to 0 or leave it empty to disable deduplication entirely.

Note: The dedup window should match your sender's retransmission policy. Most clinical systems retry within minutes or hours, so the 14-day default provides a wide safety margin.

Encoding and character sets

HL7 messages can arrive in different character encodings depending on the sending system's locale and configuration.

Configuration

FieldDefaultDescription
Default character encodingUTF-8Encoding used to decode message bytes
Use MSH-18 encodingOnRead the encoding from MSH-18 instead of using the default

Available encodings: UTF-8, ISO-8859-1, Windows-1252, US-ASCII.

MSH-18 encoding detection

When Use MSH-18 encoding is on, Zato reads the character set identifier from the MSH-18 field of each incoming message and maps it to a Python codec:

MSH-18 valuePython codec
ASCIIus-ascii
8859/1iso-8859-1
8859/2iso-8859-2
8859/3iso-8859-3
8859/4iso-8859-4
8859/5iso-8859-5
8859/6iso-8859-6
8859/7iso-8859-7
8859/8iso-8859-8
8859/9iso-8859-9
UNICODE UTF-8utf-8

If MSH-18 is empty or contains an unrecognized value, the default character encoding is used.

ACK responses are always encoded with the default character encoding.

Note: UTF-16 and UTF-32 are not supported over MLLP because their byte values conflict with MLLP framing bytes (0x0B, 0x1C, 0x0D).

Message tolerance

Real-world clinical systems do not always produce standard-compliant HL7 messages. The Tolerance tab provides two groups of toggles: wire-level preprocessing (applied to raw bytes before parsing) and parser-level tolerance (content fixups applied by the Rust ER7 parser during parse_hl7).

Wire-level preprocessing

All toggles are on by default.

ToggleWhat it does
Normalize line endingsConverts LF (\n) and CRLF (\r\n) to CR (\r), which is the HL7-standard segment separator. Without this, messages from Windows-based systems would fail to parse.
Force standard delimitersRewrites MSH-2 to ^~\& and translates all field, component, subcomponent, and repetition delimiters in the message body to the standard characters. Handles senders that use non-standard delimiters.
Repair truncated MSHPads the MSH segment if it has fewer than 12 fields. Some legacy systems send minimal MSH headers missing optional trailing fields.
Split concatenated messagesSplits multiple messages received in a single MLLP frame. Each sub-message starts with MSH. Each is routed and processed independently.
Use MSH-18 encodingReads the character encoding from MSH-18 (see the Encoding tab for details).

Parser-level tolerance

All toggles are on by default unless noted otherwise.

ToggleDefaultWhat it does
Fill empty OBX-2 value typeonWhen OBX-2 is empty but OBX-5 contains data, fills OBX-2 with ST. Common with lab systems that omit the value type for string results.
Replace invalid OBX-2 value typeonReplaces unrecognized OBX-2 data types with ST so the observation value can still be accessed.
Strip orphan escape charactersonRemoves stray backslash characters that do not form a valid HL7 escape sequence.
Clear OBX-8 literal nullonClears OBX-8 (Abnormal Flags) when it contains the literal string null instead of a valid flag.
Strip multi-quote sequencesonStrips sequences of two or more consecutive double-quote characters that some systems emit as empty-field placeholders.
Pad short encoding charactersonPads MSH-2 with standard encoding characters when the sender provides fewer than the required four.
Fix off-by-one field indexoffRemoves a spurious empty first field from non-MSH segments. Some legacy systems prepend a leading field separator that shifts all field indices by one.

Processing order

Tolerance preprocessing runs in this order:

  1. Decode bytes using MSH-18 or default encoding
  2. Normalize line endings
  3. Repair truncated MSH
  4. Force standard delimiters
  5. Split concatenated messages
  6. Apply parser-level tolerance fixups (OBX normalization, escape cleanup, encoding character padding, field index correction)
  7. Route and parse each resulting message

Tip: If you are integrating a new clinical system and messages fail to parse, check the server logs first. Enabling Log messages in the Logging tab will show the raw payload before and after tolerance preprocessing.

Batch messages

HL7 supports batch envelopes using BHS (Batch Header) and FHS (File Header) segments. Some systems wrap multiple messages in a single batch transmission.

How Zato handles batches

If the payload of an MLLP frame starts with BHS| or FHS|, Zato treats the entire frame as a batch:

  • Routing uses the first embedded MSH| line in the batch
  • The service receives the full raw batch string as its input
  • Parsing is typically turned off (Parse on input = off) so the service can unpack individual messages from the batch

Example batch payload

FHS|^~\&|SENDER|FAC|RECEIVER|FAC|20240315120000
BHS|^~\&|SENDER|FAC|RECEIVER|FAC|20240315120000
MSH|^~\&|SENDER|FAC|RECEIVER|FAC|20240315120000||ADT^A04|MSG001|P|2.9
EVN|A04|20240315120000
PID|1||12345||SMITH^JOHN
MSH|^~\&|SENDER|FAC|RECEIVER|FAC|20240315120001||ADT^A04|MSG002|P|2.9
EVN|A04|20240315120001
PID|1||67890||DOE^JANE
BTS|2
FTS|1

Service for batch processing

# -*- coding: utf-8 -*-

# Zato
from zato.server.service import Service

class BatchHandler(Service):

    def handle(self) -> 'None':

        # .. the raw batch string, not parsed ..
        raw_batch:'str' = self.request.input

        # .. split on MSH boundaries ..
        messages = raw_batch.split('MSH|')

        # .. skip the FHS/BHS preamble (first element) ..
        for raw_message in messages[1:]:
            full_message = 'MSH|' + raw_message.rstrip()
            self.logger.info('Processing message: %s', full_message[:80])

Note: Set Parse on input to off for batch channels. The parser expects a single message, not a batch envelope.

Logging and observability

The Logging tab controls how much detail Zato records about MLLP traffic.

Configuration

FieldDefaultDescription
Logging levelINFOMinimum severity for log entries (DEBUG, INFO, WARNING, ERROR)
Log messagesOffWhen on, logs the full request and response payloads

What gets logged

Always logged (at the configured level):

  • Connection open and close events, with message count per connection
  • Routing decisions (which channel matched)
  • Warnings for framing anomalies, split messages, and unmatched routes
  • Errors from service invocations

When Log messages is on:

  • Full raw ER7 payload of each incoming message
  • Full ACK response sent back to the sender

Note: Enable Log messages only during development and debugging. HL7 messages contain protected health information (PHI) and logging them in production may violate compliance requirements.

Defining MLLP channels via enmasse

Instead of configuring channels through the web admin dashboard, you can define them declaratively in a YAML file and import them with enmasse. This is especially useful for automated deployments and version-controlled configuration.

channel_hl7_mllp:

  - name: my.hl7.lab-results
    service: hl7.process-lab-results
    should_validate: true
    msh9_message_type: ORU

  - name: my.hl7.admissions
    service: hl7.process-admissions
    msh9_message_type: ADT
    msh9_trigger_event: A01

See the enmasse reference for the complete list of fields.


Schedule a meaningful demo

Book a demo with an expert who will help you build meaningful systems that match your ambitions

"For me, Zato Source is the only technology partner to help with operational improvements."

- John Adams
Program Manager of Channel Enablement at Keysight