HL7 MLLP tutorial - Route clinical messages

Route HL7 MLLP messages between clinical systems.

With Zato, you can route HL7 MLLP messages between clinical systems in one of two ways, and this tutorial will show you both.

Demo config

When to use no-code routing

  • You pick the receiving destinations in the Dashboard - no code required
  • You can assign matching criteria in the Dashboard, e.g. routing only messages whose MSH-3 field is such and such
  • All destinations receive the same message in a fan-out fashion, in their own format, e.g. FHIR destinations receive FHIR bundles
  • If needed, Python code can be still used to work with the messages before they are delivered

Setting up Zato

  1. Install Zato through Docker and open the Dashboard at http://localhost:8183
  2. Confirm the HL7 demo config is imported under System ▹ Demo config Show me where

Accept and route incoming messages

In Zato, incoming messages are accepted through channels, e.g. MLLP channels, REST channels, and others.

So, with the Dashboard open, go to Connections ▹ Channels ▹ HL7 ▹ MLLP, click Create a new MLLP channel and complete the wizard:

  1. Name: Main HL7 Feed
  2. Default: on - a default channel receives every message that no other channel matches
  3. On the second step, open Incoming messages go to and pick all three demo connections
  4. Go to the review page and click Save

Clicking "Save" created a new MLLP channel which waits for incoming messages now on localhost:11553, and note that we're skipping many customization options you can set, they're documented in their own chapters.

Send a test message

Send this ADT message to localhost:11553 with any MLLP client:

MSH|^~\&|WARD_APP|EAST_WING|EHR|MAIN_HOSPITAL|20260315120000||ADT^A01^ADT_A01|MSG00001|P|2.9
EVN|A01|20260315120000
PID|1||12345^^^FAC^MR||SMITH^JOHN^A||19800115|M
PV1|1|I

What happens next is that the MLLP client receives AA back - indicating the channel accepted the message for delivery, and then the delivery to the destinations takes place.

In other words, the channel returns the acknowledgment first and delivers the message afterwards, so the acknowledgment does not depend on how long delivery takes.

But how is such an incoming message transformed? In what format is it delivered to the destinations?

The answer depends on what kind of destination it is. If it's MLLP, it will be delivered as is. For FHIR, it will be automatically converted into a FHIR bundle. For REST, it will be sent as the body of an HTTP request, with the message exactly as it arrived.

You can click below to see both - the FHIR bundle the test message converts into and the HTTP request the REST archive receives:

The FHIR bundleJSON
{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "request": {
        "method": "POST",
        "url": "MessageHeader"
      },
      "fullUrl": "urn:uuid:07c9c1d7-d6e4-534a-9ded-772f995bc215",
      "resource": {
        "resourceType": "MessageHeader",
        "eventCoding": {
          "system": "http://terminology.hl7.org/CodeSystem/v2-0003",
          "code": "A01"
        },
        "source": {
          "name": "WARD_APP",
          "endpoint": "urn:zato:hl7v2:authority:WARD_APP"
        },
        "destination": [
          {
            "name": "EHR",
            "endpoint": "urn:zato:hl7v2:authority:EHR"
          }
        ],
        "focus": [
          {
            "reference": "urn:uuid:26e8a78d-2605-5c33-afcf-5b4409d14be5"
          }
        ]
      }
    },
    {
      "request": {
        "method": "POST",
        "url": "Patient"
      },
      "fullUrl": "urn:uuid:26e8a78d-2605-5c33-afcf-5b4409d14be5",
      "resource": {
        "resourceType": "Patient",
        "identifier": [
          {
            "value": "12345",
            "system": "urn:zato:hl7v2:authority:FAC",
            "type": {
              "coding": [
                {
                  "system": "http://terminology.hl7.org/CodeSystem/v2-0203",
                  "code": "MR"
                }
              ]
            }
          }
        ],
        "name": [
          {
            "family": "SMITH",
            "given": [
              "JOHN",
              "A"
            ]
          }
        ],
        "birthDate": "1980-01-15",
        "gender": "male"
      }
    },
    {
      "request": {
        "method": "POST",
        "url": "Encounter"
      },
      "fullUrl": "urn:uuid:d52ca6a2-5f4c-51d3-871e-8f2f5777074c",
      "resource": {
        "resourceType": "Encounter",
        "subject": {
          "reference": "urn:uuid:26e8a78d-2605-5c33-afcf-5b4409d14be5"
        },
        "class": {
          "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
          "code": "IMP"
        },
        "status": "in-progress"
      }
    }
  ],
  "identifier": {
    "value": "MSG00001"
  },
  "timestamp": "2026-03-15T12:00:00+00:00"
}
The REST requestHTTP
POST /hl7/archive HTTP/1.1
Host: 10.152.5.17
Content-Type: text/plain
Content-Length: 171

MSH|^~\&|WARD_APP|EAST_WING|EHR|MAIN_HOSPITAL|20260315120000||ADT^A01^ADT_A01|MSG00001|P|2.9
EVN|A01|20260315120000
PID|1||12345^^^FAC^MR||SMITH^JOHN^A||19800115|M
PV1|1|I

Audit log and monitoring

Each channel has an audit log populated with all the messages it receives, along with information what happened next.

This lets you see what actually took place, when, and why, giving you complete observability into the environment, and letting you answer questions about your interfaces.

The audit log is automatically connected to alerts - so if your feeds go silent,

That's it, job well done

And that's it really, that's all there is to it. You create a channel, select your destinations, and the platform does the rest. Naturally, there are many ways to customize the process, documented below, but in a nutshell, it's as easy as that.

When to use Python code routing

  • Use it when the decision depends on what is in the message - the channel invokes your service with each message and your code decides where it goes
  • You can still assign routing criteria and other options in the Dashboard
  • Python code can transform the messages into any format you need, with complete control over where each one is delivered

Setting up Zato

  1. Install Zato through Docker and log into the Dashboard at http://localhost:8183
  2. Make sure the HL7 demo config is imported under System ▹ Demo config Show me where

Write the routing service

In Zato, incoming messages are handled by services - Python classes that a channel invokes with each message it accepts.

So, with the Dashboard open, go to the IDE, create a file called hl7_api.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 MessageHandler(Service):
    """ Routes each incoming HL7 message by its type.
    """

    def handle(self) -> 'None':

        # The decision needs one field - the message code from MSH-9 ..
        msg:'HL7Message' = self.request.input
        message_type = msg.msh.message_type.message_code

        # .. ORU results are forwarded to the EHR ..
        if message_type == 'ORU':
            connection_name = 'demo.hl7.forward.ehr'
            result = self.mllp[connection_name].send(msg)
            self.logger.info(f'EHR replied {result.ack_code}')

        # .. and everything else stays where it is.
        else:
            self.logger.info(f'Not forwarding {message_type}')

The moment it deploys, the service is available under the name hl7-api.message-handler - derived from the file and class names - and what it needs now is a channel to invoke it.

Create the channel

Go to Connections ▹ Channels ▹ HL7 ▹ MLLP, click Create a new MLLP channel and complete the wizard, this time picking a service instead of destinations:

  1. Name: HL7 Message Router
  2. On the second step, open Service invoked and pick hl7-api.message-handler
  3. Go to the review page and click Save

Clicking "Save" created a channel that waits for incoming messages on localhost:11553 and invokes your service with each one. A channel with no match rules matches every message, so the router receives everything that arrives - even if the no-code channel from the other tab exists too, because a default channel only gets what nothing else matched.

Send a test message

Send this ADT admission to localhost:11553 with any MLLP client:

MSH|^~\&|WARD_APP|EAST_WING|EHR|MAIN_HOSPITAL|20260315120000||ADT^A01^ADT_A01|MSG00001|P|2.9
EVN|A01|20260315120000
PID|1||12345^^^FAC^MR||SMITH^JOHN^A||19800115|M
PV1|1|I

The client receives AA back and the server log confirms your code made its decision:

INFO - Not forwarding ADT

But an admission is what this service leaves alone, so how about a message it does forward? Send a lab result next:

MSH|^~\&|LAB_APP|EAST_WING|EHR|MAIN_HOSPITAL|20260315124500||ORU^R01^ORU_R01|MSG00002|P|2.9
PID|1||12345^^^FAC^MR||SMITH^JOHN^A||19800115|M
OBR|1|845439^LAB||1554-5^GLUCOSE^LN|||20260315123000
OBX|1|NM|1554-5^GLUCOSE^LN||182|mg/dL|70-105|H|||F

This time your code sends the message on to demo.hl7.forward.ehr and logs the acknowledgment the receiving system replied with:

INFO - EHR replied AA

And that's all it takes

You deploy a service, point a channel at it, and where each message goes is a decision your Python code makes. The audit log, alerts and everything else from no-code routing work the same way here, and the chapters below cover what more channels can do.

What you built

  • A channel that delivers an incoming HL7 feed to destinations picked in the Dashboard, without code
  • A routing service that reads one MSH field and decides where each message goes
  • A verified path for each - the client's acknowledgment, the audit log's flow view and the server log

What next

  • Connect your AI to ask more questions about Zato and to build your interfaces
  • Play around with the demo config, point it to your own destinations, add more input matching conditions
  • Work in Python on more advanced transformations and routing conditions
  • At any time, you can go to System ▹ Demo config, slide the slider off to remove the demo config, and slide it back on to import it again, so don't worry about breaking anything

What else channels can do

FeatureWhat it does
Routing to selected destinations onlyA service picks which destinations receive a given message
Match rulesSelect a channel by message type, sending application or facility
REST bridgeReceive the same messages over HTTP
Typed parsing and navigationmsg.pid.patient_name.family_name instead of positional offsets
Batch filesFiles wrapped in FHS and BHS headers
AcknowledgmentsWhat AA, AE and AR mean and where error details appear
Deduplication, tolerance and encodingHandle duplicated messages and senders that deviate from the standard
Enmasse YAMLThe same channels defined declaratively, in version control
The audit logEvery message with its acknowledgment, searchable by patient identifier


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