Sending an HL7 v2 message is easy. Open a TCP socket, wrap the message in MLLP framing, write it, and read whatever comes back. You can do it in twenty lines of code, and there are plenty of tools that do.
I am a Senior Data Integration Engineer in healthcare software. My day job is interoperability: getting hospital data (ADT, lab results and DICOM imaging) into third-party databases that applications depend on, through integration engines like Apache NiFi and Mirth Connect. Testing those interfaces means sending HL7 v2 messages into them over and over, and checking what happens when things go wrong.
Knowing whether the message actually arrived, and what to do when it didn't, is the hard part. I found that out while building HL7 Sender,an open-source desktop app and CLI for testing HL7 v2 interfaces. Integration engineers in the v2-to-FHIR
stream on chat.fhir.org then read the code and found more. This is what I learned, in the order a message
meets it.
1. An ACK timeout has to be a deadline, not a read timeout
MLLP frames each message as <VT> message <FS><CR> (bytes 0x0B, then 0x1C 0x0D). The sender writes a frame and waits for an acknowledgment frame back.
The obvious implementation sets a socket read timeout of, say, 30 seconds. But a read timeout restarts on every byte that arrives. A receiver that sends one byte every 29 seconds, through a struggling proxy or a half-broken engine, keeps the connection alive forever and never finishes the ACK.
So the ACK timeout is a total deadline for the whole response. Before each blocking read, HL7 Sender sets the socket timeout to the time that is left, not the full timeout. Thirty seconds means thirty seconds.
2. An ACK is only an ACK for this message if MSA-2 says so
A typical acknowledgment:
MSH|^~\&|RECEIVER|HOSP|SENDER|LAB|20261008125049||ACK^R01^ACK|ACK0001|P|2.5.1
MSA|AA|MSG00042
MSA-1 is the code (AA = accepted). MSA-2 is the control ID of the message being acknowledged, and it must equal the MSH-10 of what you sent.
If it doesn't match, the ACK belongs to some other message, often an earlier one that timed out and whose ACK finally turned up. Counting that as success means silently losing a message. HL7 Sender reports
it as CONTROL_ID_MISMATCH and retries.
There's a second part to this: after any ambiguous response (a timeout, a mismatch, something that isn't an ACK), the sender closes the connection. Otherwise the late ACK for message 41 arrives on the same connection while you're waiting for message 42's, and you're back to the first problem.
3. Not all negative ACKs mean the same thing
HL7 v2 has two acknowledgment modes, and their codes look like pairs:
| Original mode | Enhanced mode (commit) |
|---|---|
AA application accept |
CA commit accept |
AE application error |
CE commit error |
AR application reject |
CR commit reject |
My first version treated them as pairs: AE and CE meant "the content is wrong, don't retry", AR and CR meant "rejected for now, retry". An Integration Engineer on chat.fhir.org pointed out that chapter 2 of the standard (section 2.9.3 in v2.5.1) gives the commit codes different jobs:
- CR is for a message type (MSH-9), version (MSH-12) or processing ID (MSH-11) the receiver won't take. Re-sending the same message can't fix that, so retrying it is pointless.
- CE is "any other reason", such as a sequence number error, which may clear by itself.
So they're now separate outcomes with their own defaults: CR goes to the dead-letter queue, CE is retried.
He also pointed out that receivers use CE and CR loosely in practice, and suggested three cases to test:
- CR for a content error (a required field missing). Still can't be fixed by re-sending: dead-letter queue after one attempt.
- CE for something transient (a lock on the receiver's database). Retry until the receiver returns CA.
- CE for a permanent content error. This one retries forever unless something stops it. The test is that it stops at the attempt limit and lands in the dead-letter queue with every CE in the history, not just the last.
Case 3 is why the retry policy is configurable per code and per destination. If you know a receiver uses CE for content errors, you set "On CE" to dead-letter for that destination and stop at the first one.
4. In enhanced mode, CA isn't the end of the story
In enhanced mode, the receiver answers in two steps:
- A commit ACK (CA, CE or CR) on the same connection: "I have your message in safe storage".
- Later, if the message's MSH-16 asks for it, an application ACK (AA, AE or AR) as a separate message, sent by the receiver on a new connection back to the sender: "and here's what my application thought of it".
If the sender marks the message as done on the CA, an application error that arrives later never reaches the message's history. That was the other thing that Integration Engineer spotted, and it took the biggest change to fix.
MSH-16 says what to expect, and the interesting part is what silence means for each value:
| MSH-16 | Application ACK arrives | Nothing arrives before the timeout |
|---|---|---|
AL (always) |
AA: done. AE/AR: failed | failed: it should have come |
ER (errors only) |
AE/AR: failed | success: no news is good news |
SU (success only) |
AA: done | failed: success would have been reported |
NE or empty |
n/a | the CA was the final answer |
HL7 Sender now keeps a CA'd message in an AWAITING_APP_ACK state, listens on a per-destination port for the receiver's application ACKs, and matches each one back by MSA-2. Some details that mattered:
- A waiting message doesn't block the queue. The receiver has committed it, so the messages behind it can go.
- A failed application ACK isn't resent automatically. The receiver already has the message. Re-sending it would create a duplicate, so it goes to the dead-letter queue for a person to decide.
- A late AA still counts. If it arrives after the timeout already failed the message, it corrects the result.
- Only the destination's own host may connect to the port. Otherwise anything on the network could complete or fail your messages.
5. A crash mid-send must not lose or reorder anything
A test tool is still worth making reliable, because the point of testing is to trust the result. The failure that matters is the process dying between "message sent" and "ACK recorded".
The rule HL7 Sender follows: write the state before the network I/O.
- Each destination has a queue in SQLite, in WAL mode with
synchronous=FULL, so a committed change survives a crash or power loss. - Before any byte is sent, the message is committed as
IN_FLIGHT. - On the next start, any
IN_FLIGHTmessage goes back to the head of its queue and is flagged as a possible duplicate, because the receiver may already have it. - Every retry reuses the same MSH-10, so a receiver that de-duplicates by control ID can do so.
- Each destination delivers strictly in order, one message at a time, so an A08 never overtakes the A01 it updates.
The CI suite tests this directly. It starts a delivery process, kills it with the equivalent of kill -9 in the middle of sending 40 messages to a deliberately slow receiver, restarts it, and checks that all 40 arrive, in their original order, with at most one received twice.
6. The receiver has to misbehave on purpose
Most of the bugs above only show up when the receiver does something wrong, and real receivers don't do that on request. So HL7 Sender includes a mock receiver that can:
- reply AE, AR, or the enhanced-mode codes, with your choice of error text;
- reply late, never reply, or close the connection;
- send an ACK with the wrong control ID, or something that isn't an ACK at all;
- send application ACKs later, following MSH-16;
- answer by rules (by message type or field value), and send follow-up messages, like an ORU result for each ORM order.
Every behaviour in sections 1 to 5 has a test that uses it.
What I'd tell someone building one
- Treat the ACK as data about a specific message, not as "a response".
- Look up what each code means in chapter 2 before deciding what to do with it.
- Decide what silence means; for application ACKs it depends on MSH-16.
- Write state before you touch the network.
- Post your work where people who know the standard better than you will read it. Every point above that I got wrong was found that way, within days.
HL7 Sender is free and MIT-licensed, with installers for Windows, macOS and Linux:
github.com/vijayamirtharajxavier/hl7-message-tcp-sender-multiple-os-supported.
If you test HL7 interfaces, I'd like to hear what breaks against your receivers.
I wrote this with help from an AI assistant; the technical details are checked against the HL7 Sender code and tests.


Top comments (0)