# 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](https://zato.io/tutorials/healthcare/hl7v2.html).

## The path of a message {#the-path-of-a-message}

```yaml
name: Receiving over MLLP
actors:
  - id: sender
    label: Sending application
  - id: channel
    label: MLLP channel
  - id: service
    label: Your service
steps:
  - from: sender
    to: channel
    label: A framed message arrives on port 11553
    align: left
  - from: channel
    to: channel
    label: Fixups, deduplication and routing
    items:
      - Wire-level fixups repair non-standard input
      - A control ID seen before answers AA at once
      - MSH fields select the channel
  - from: channel
    to: service
    label: Invoked with a typed HL7Message object
  - from: service
    to: channel
    kind: return
    label: Your code returns
  - from: channel
    to: sender
    kind: return
    label: AA returns to the sender
    align: left
```

The channel answers AA when your service returns, AE when it raises `HL7ApplicationError` because the message itself is at fault, and AR when it raises any other exception or when no channel matches the message at all - see [acknowledgments](https://zato.io/docs/dev/healthcare/hl7v2/mllp/acknowledgments.html) for the codes in both directions.

## Write the service {#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**:

```python
# -*- 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 needs ..
        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}')
```

Once deployed, 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:

```text
INFO - Received CTL001 for patient SMITH
```

[Field access](https://zato.io/docs/dev/healthcare/hl7/v2/field-access/index.html) 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](https://zato.io/tutorials/healthcare/hl7v2.html) builds such a channel first.

## Create the channel {#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**

[Video: New MLLP channel](https://zatosource-production.b-cdn.net/docs/gfx/channels/hl7-mllp-create-lab-results.webm?v=1791648491)

Dashboard menu: Connections > Channels > HL7 > MLLP

The same channel can also come from an [enmasse](https://zato.io/docs/admin/enmasse-reference.html#hl7-mllp-channels) YAML file, which is how version-controlled deployments define it:

```yaml
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](https://zato.io/docs/dev/healthcare/audit-log.html) together with that acknowledgment.

Every channel field - framing, tolerance fixups, deduplication, encoding, logging - is listed with its default in the [channel configuration reference](https://zato.io/docs/dev/healthcare/hl7v2/mllp/config.html).

## Route by MSH fields {#route-by-msh-fields}

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

| Field | MSH position | Example values |
| --- | --- | --- |
| **Sending application** | MSH-3 | `WARD_APP`, `LAB_SYS` |
| **Sending facility** | MSH-4 | `MAIN_FAC`, `EAST_CLINIC` |
| **Receiving application** | MSH-5 | `SCHEDULING`, `HIS` |
| **Receiving facility** | MSH-6 | `MAIN_FAC` |
| **Message type** | MSH-9.1 | `ADT`, `ORM`, `ORU` |
| **Trigger event** | MSH-9.2 | `A04`, `O01`, `R01` |
| **Processing ID** | MSH-11 | `P` (production), `T` (training), `D` (debugging) |
| **Version** | MSH-12 | `2.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](https://zatosource-production.b-cdn.net/docs/gfx/channels/hl7-mllp-match.webp?v=1791648424)

Dashboard menu: Connections > Channels > HL7 > MLLP

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](https://zato.io/docs/dev/healthcare/audit-log.html) still records the message, filed under a reserved name of its own, so unmatched traffic stays searchable.

## The REST bridge {#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](https://zatosource-production.b-cdn.net/docs/gfx/channels/hl7-mllp-rest.webp?v=1791648426)

Dashboard menu: Connections > Channels > HL7 > MLLP

With the bridge on, the same message arrives over HTTP and the acknowledgment comes back in the response body:

```bash
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 {#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](https://zato.io/docs/dev/healthcare/hl7/v2/batch/index.html) shows the classes and a service that does it.

## Ports and TLS {#ports-and-tls}

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

| Port | Purpose |
| --- | --- |
| **11553** | HL7 v2 over MLLP, plaintext |
| **11554** | The 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](https://zato.io/tutorials/devops/env-variables.html).

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 as described in the [SSL and TLS documentation](https://zato.io/docs/admin/security/ssl/index.html), which covers REST traffic as well.

## See also {#see-also}

- [The HL7 MLLP tutorial](https://zato.io/tutorials/healthcare/hl7v2.html) - Route a feed end to end, with test messages and the audit log
- [Channel configuration reference](https://zato.io/docs/dev/healthcare/hl7v2/mllp/config.html) - Every channel field with its default and behavior
- [Sending over MLLP](https://zato.io/docs/dev/healthcare/hl7v2/mllp/outgoing.html) - Outgoing connections, self.mllp and delivery retries
- [Acknowledgments](https://zato.io/docs/dev/healthcare/hl7v2/mllp/acknowledgments.html) - What AA, AE and AR mean and who sees the error details
- [The audit log](https://zato.io/docs/dev/healthcare/audit-log.html) - Every message with its acknowledgment, searchable by patient identifier

## Learn more {#learn-more}

- [Healthcare interface engine](https://zato.io/docs/dev/healthcare/) - Clinical messages, FHIR, conversion and operations
- [HL7 v2 parsing](https://zato.io/docs/dev/healthcare/hl7/v2/parsing/) - Messages as typed objects, with field access and validation
- [FHIR integrations](https://zato.io/docs/dev/healthcare/hl7/fhir/) - Read and write FHIR resources, with paths, bundles and extensions
- [HL7 v2 to FHIR](https://zato.io/docs/dev/healthcare/hl7/to-fhir/) - Converting v2 messages into FHIR bundles, codes and references included
- [EDIFACT](https://zato.io/docs/dev/healthcare/edifact/) - Parsing interchanges, dialects and the transports they arrive on
- [Transformation](https://zato.io/docs/dev/healthcare/transformation/) - One message in, another standard out, in ordinary Python
- [Audit log](https://zato.io/docs/dev/healthcare/audit-log.html) - Every message with its acknowledgment, searchable by patient identifier
- [AI in clinical interfaces](https://zato.io/docs/dev/healthcare/ai/) - Services calling LLMs and AI agents calling clinical services as tools
