:::caution Experimental Feature This feature is highly experimental and subject to breaking changes in future releases. :::
HL7v2 Ingestion
Haste Health can ingest legacy HL7v2 messages (ADT, ORU, ORM, etc.) over MLLP and convert them into FHIR resources using a template you write. This is separate from the main FHIR server process — it's a standalone receiver you run yourself, wired to your own conversion templates.
There are two independent pieces:
- Parsing (
haste-hl7v2): turns raw pipe-delimited HL7v2 text into a structured representation (segments → fields → components → subcomponents). - Conversion (
haste-fhir-converter): a Jinja-style template engine that turns that parsed structure into FHIR resources.
How It Works
The receiver (haste-health hl7v2 receiver) runs a blocking TCP listener. For each MLLP-framed message it receives, it:
- Reads the MLLP frame (
<VT>...<FS><CR>) and decodes the raw HL7v2 text. - Parses the message into segments/fields/components/subcomponents.
- Renders your configured Jinja template with the parsed message bound to a
hl7v2variable, expecting the template to output FHIR JSON. - If the rendered output is a
Bundleof typebatchortransaction, submits it via the corresponding FHIR batch/transaction endpoint; otherwise creates the single resource directly. - Sends an MLLP-level ACK (
0x06) on success, or NAK (0x15) if decoding, template rendering, or the FHIR write fails.
This is a single-threaded, one-connection-at-a-time loop — the listener accepts a connection, then processes frames on it synchronously until the connection closes, before accepting the next one.
Writing a Conversion Template
Templates are .jinja/.j2 files loaded from a directory you point the receiver at; the file's path (relative to that directory, extension stripped) is its template name.
The parsed message is available in the template as hl7v2. Access a segment by its 3-letter ID, then index into its fields:
{
"resourceType": "Patient",
"id": "{{ hl7v2.PID[2] }}",
"name": [
{
"family": "{{ hl7v2.PID[4][0] }}",
"given": ["{{ hl7v2.PID[4][1] }}"]
}
]
}
Two things to know about that indexing:
- Fields are 0-indexed and offset by one from the conventional HL7v2 field number.
hl7v2.PID[2]is PID-3 (Patient ID),hl7v2.PID[4]is PID-5 (Patient Name), because index0is the first field after the 3-character segment ID. - Further indices descend into repetitions, components, and subcomponents, collapsing automatically when there's only one. For a non-repeating PID-5 (XPN),
hl7v2.PID[4][0]reaches the first component (family name) directly; if PID-5 repeats (multiple names on the message), you'd index the repetition first, then the component. - A segment repeats if the message has more than one (e.g. multiple
NK1segments) — indexing a segment by name always returns a list, so template logic needs a{% for %}loop for multi-segment types, not direct indexing.
:::note hl7v2_segments filter is not implemented
A | hl7v2_segments Jinja filter is registered, but it's a stub — it ignores its input and always returns the literal string "hl7v2_segments". Don't use it; use direct segment/field indexing as shown above instead.
:::
There is currently no FHIRPath filter available in Jinja templates (the crate has a placeholder for one, but it isn't implemented), so all extraction from the HL7v2 side has to go through direct indexing, not FHIRPath expressions.
Running the Receiver
The receiver is a subcommand of the Haste Health server binary — the same binary.
haste-health hl7v2 receiver \
--address 0.0.0.0 \
--port 2575 \
--main patient_adt \
--template-dir ./templates
--address/--port: where the TCP listener binds.--template-dir: directory scanned (recursively) for.jinja/.j2files at startup.--main: the template name (relative path under--template-dir, without extension) to render for every incoming message — there's no per-message-type template dispatch built in. If you need to branch on message type (ADT^A01vsORU^R01), that branching has to happen inside the one template you point--mainat (e.g. via{% if hl7v2.MSH[8] == "ADT^A01" %}).
Authenticating the Receiver
The receiver writes converted resources to your FHIR API as an authenticated client, using the same profile-based config as the server binary's other CLI commands (stored at ~/.haste_health/config.toml, separate from the npm CLI's config).
- Register an OAuth client with system-level scope — see Client Registration and Scopes & Permissions. The receiver requests
openid system/*.*, so the client needs access to that scope. - Create and activate a profile:
haste-health config create-profile \
--name hl7v2-receiver \
--r4-url https://api.haste.health/w/[tenant]/[project]/api/v1/fhir \
--discovery-uri https://api.haste.health/.well-known/openid-configuration/w/[tenant]/[project] \
--id <client_id> \
--secret <client_secret>
haste-health config set-active-profile --name hl7v2-receiver
- Run
haste-health hl7v2 receiver ...as above — it reads the active profile and fetches aclient_credentialstoken before processing any messages.
Debugging a Message
To see how a message parses without running it through a template, use the $hl7v2-parse system operation — it returns the fully parsed segment/field/component structure as FHIR JSON:
curl -X POST "https://api.haste.health/w/[tenant]/[project]/api/v1/fhir/\$hl7v2-parse" \
-H "Content-Type: application/fhir+json" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-d '{
"resourceType": "Parameters",
"parameter": [
{ "name": "hl7v2", "valueString": "MSH|^~\\&|...\rPID|1||12345||Doe^John\r" }
]
}'
This is useful for working out field indices while writing a template, independent of the receiver or MLLP.
Network & Security
- The MLLP socket has no authentication or TLS. We will support mTLS but for now any host that can reach the configured address/port can submit messages; there's no API key, mTLS, or IP allowlist at the MLLP layer. Network-level controls (VPN, firewall rules, private networking) are your responsibility.
- ACKs are minimal. The receiver replies with a bare MLLP commit-level
ACK/NAKbyte (0x06/0x15), not a full HL7v2ACKmessage withMSA-1/MSA-2fields. Sending systems that validate the acknowledgment's HL7v2 content (rather than just the MLLP commit byte) may not recognize this as a valid response.
Limitations
- No per-message-type template routing — one
--maintemplate handles every message; branching by message type is your template's responsibility. - No TLS or authentication on the MLLP socket itself.
- ACK/NAK are bare MLLP commit bytes, not full HL7v2 acknowledgment messages.