Receiving HL7 v2 over MLLP

Accept HL7 v2 messages from clinical systems, as typed Python objects.

An MLLP channel reads each frame off the wire, parses it into a typed Python object and either invokes your service with it or delivers it to the destinations you picked, answering the sender with an acknowledgment either way.

This page shows how to write the receiving service, create the channel, route messages by their MSH fields and accept the same messages over REST. To route a feed end to end, with test messages and the audit log, follow the HL7 MLLP tutorial.

How a message travels

The channel answers AA when your service returns, AE when it raises an exception and AR when no channel matches the message at all - see acknowledgments for the codes in both directions.

Write the service

A channel invokes one service with each message it accepts. With Parse each message on input on - the default - the service receives a typed HL7Message object as self.request.input, and with the toggle off it receives the raw ER7 string instead.

With the Dashboard open, go to the IDE, create a file called hl7_receiving.py, paste the code below and click Deploy:

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

# Zato
from zato.server.service import Service

# #####################################################################
# #####################################################################

if 0:
    from zato.hl7v2.base import HL7Message

# #####################################################################
# #####################################################################

class LabResults(Service):
    """ Receives lab results over MLLP and logs each one.
    """

    def handle(self) -> 'None':

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

        # .. read the fields this integration cares about ..
        control_id  = msg.msh.message_control_id
        family_name = msg.pid.patient_name.family_name

        # .. and log what arrived.
        self.logger.info(f'Received {control_id} for patient {family_name}')

The moment it deploys, the service is available under the name hl7-receiving.lab-results - derived from the file and class names - and each message the channel accepts produces one log line:

INFO - Received CTL001 for patient SMITH

Field access covers everything the typed object exposes - named attributes, components, repetitions and path expressions.

A channel can also deliver each message to destinations picked in the Dashboard, no service and no code required - the tutorial builds such a channel first.

Create the channel

Go to Connections ▹ Channels ▹ HL7 ▹ MLLP, click Create a new MLLP channel and complete the wizard:

  1. Name: Lab Results Inbound
  2. On the second step, open Service invoked and pick hl7-receiving.lab-results
  3. Go to the review page and click Save

The same channel can also come from an enmasse YAML file, which is how version-controlled deployments define it:

channel_mllp:

  - name: Lab Results Inbound
    service: hl7-receiving.lab-results

Either way, the channel now waits for messages on localhost:11553. Send any HL7 v2 message there with an MLLP client and the client receives AA back, while the message itself appears in the audit log together with that acknowledgment.

Every channel field - framing, tolerance fixups, deduplication, encoding, logging - is listed with its default in the channel configuration reference.

Route by MSH fields

All MLLP channels share one listener, so when several exist, the MSH header of each incoming message decides which channel receives it. A channel can match on any of these fields:

FieldMSH positionExample values
Sending applicationMSH-3WARD_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)
VersionMSH-122.5, 2.9

Only filled fields are checked - empty fields match any value - and all comparisons are case-insensitive. The first channel whose criteria all match receives the message.

For example, to split admissions and orders between two services, create two channels:

  • ADT Inbound - message type ADT, invoking the admissions service
  • Orders Inbound - message type ORM, invoking the orders service
Match criteria

A channel with the Default toggle on receives every message that no other channel matches - one default channel is allowed at a time. When no channel matches and no default exists, the sender receives AR and the audit log still records the message, filed under a reserved name of its own, so unmatched traffic stays searchable.

The REST bridge

Some clinical systems send HL7 v2 over HTTP rather than MLLP. With REST turned on, the same channel accepts those messages too - two fields hold the URL path and the security definition HTTP senders authenticate with:

REST options

With the bridge on, the same message arrives over HTTP and the response body carries the acknowledgment:

curl -X POST \
  -u hl7user:hl7password \
  -H "Content-Type: text/plain" \
  -d 'MSH|^~\&|WARD_APP|EAST_WING|EHR|HOSP|20260315||ADT^A01|MSG00001|P|2.9
EVN|A01|20260315120000
PID|1||12345^^^FAC^MR||SMITH^JOHN^A||19800115|M
PV1|1|I' \
  http://localhost:11223/hl7/lab

The body arrives with newline separators rather than the CR the standard requires, and the channel's Normalize line endings fixup - on by default - converts them before parsing.

The same service handles messages from both transports. Turning MLLP off leaves REST as the channel's only transport, for senders that support HTTP exclusively.

The bridge itself is a companion REST channel named hl7.rest.<channel-name>, created and removed together with the MLLP channel - there is nothing to manage about it separately.

Batches over MLLP

When the payload of a frame starts with BHS| or FHS|, the channel treats the whole frame as one batch - the first MSH| line inside it makes the routing decision, the service receives the entire raw batch string and one acknowledgment covers the batch.

Unpack the string with parse_batch_or_file - batch processing shows the classes and a service that does it.

Ports and TLS

MLLP has a public TCP port of its own, separate from the REST and HTTP traffic on port 11223:

PortPurpose
11553HL7 v2 over MLLP, plaintext
11554The TLS variant of 11553

There is no per-channel port - all channels listen on the same one and the MSH criteria above dispatch the traffic. In Docker, publish port 11553 when you create the container, e.g. -p 11553:11553, and 11554 as well if senders connect over TLS. In firewalls, allow inbound TCP to these ports from the addresses of your sending systems.

The numbers come from the Zato_Port_MLLP and Zato_Port_MLLP_SSL environment variables - see the environment variables reference.

Port 11554 terminates TLS in front of the MLLP listener, so senders connect with tls://your-host:11554 while your channels and services remain unchanged. Certificates are configured the same way as for REST traffic - see the SSL and TLS documentation.

See also

PageWhat it covers
The HL7 MLLP tutorialRoute a feed end to end, with test messages and the audit log
Channel configuration referenceEvery channel field with its default and behavior
Sending over MLLPOutgoing connections, self.mllp and delivery retries
AcknowledgmentsWhat AA, AE and AR mean and who sees the error details
The audit logEvery message with its acknowledgment, searchable by patient identifier

Learn more


Schedule a meaningful demo

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

"We evaluated 12 integration platforms and Zato was the only one to score 100%."

Philip Zuñiga, Assistant Professor, University of the Philippines