Skip to content
LambogeniusDocs

HL7 Edge Gateway

HL7 v2 to FHIR

Turn ADT, results, orders and immunizations into FHIR R4 transaction Bundles, and send them to any FHIR server.

Runs onWindows · Linux · macOS · DockerFree developer license for test traffic. Production traffic needs a paid license.

A transform's fhir step turns an HL7 v2 message into a FHIR R4 transaction Bundle, with templates built into the gateway, and a fhir destination sends it to a FHIR server: Azure Health Data Services, the Google Cloud Healthcare API, HAPI, Smile, Firely, an EHR's FHIR API, or any other R4 server. The queue keeps the HL7 message, so FHIR is just one more destination of a route; the others still get HL7. FHIR arrived in 0.7.0.

[[route]]
name = "to-fhir"
mode = "store_and_forward"
source = { type = "mllp", listen = "0.0.0.0:7890" }
store_and_forward = { on_reject = "dead_letter" }

[[route.destinations]]
name = "fhir"
type = "fhir"
base_url = "https://contoso-fhir.fhir.azurehealthcareapis.com"
auth = { type = "managed_identity" }
transform = { step = [ { fhir = { timezone = "America/Toronto" } } ] }

What each message becomes

MessageResources
ADT (any event)Patient, Encounter (not for A28 and A31, which are about the person), RelatedPerson for each NK1, AllergyIntolerance for each AL1, Condition for each DG1, Coverage for each IN1, Practitioner for each doctor
ORU^R01DiagnosticReport for each OBR, an Observation for each OBX (numbers, coded values, text, ranges such as <0.5, notes from NTE), Specimen (SPM), ServiceRequest, Patient and Encounter
ORM^O01, OML^O21ServiceRequest for each ORC and its OBR, its status from the order control (NW, CA, DC, SC with ORC-5 …), priority, reason; Patient and Encounter
VXU^V04Immunization for each RXA: given or refused (RXA-20, RXA-18), vaccine, lot, expiry, manufacturer, dose, route and site (RXR), who gave it; Patient

The template is chosen by message type and event (ORU_R01.liquid), then the message structure (MSH-9.3), then the type alone (ADT.liquid). A message type with none is rejected for that destination.

The fhir step

fhir = { timezone = "America/Toronto" }            # required: an IANA time zone name
fhir = { timezone = "America/Toronto",
         bundle = "transaction",                   # or "collection", to look at (not for a fhir destination)
         templates = "my-fhir",                    # your templates and tables.toml, relative to the configuration
         identifier_system_base = "https://hospital.example/id/" }   # default "urn:id:"

timezone is required: HL7 times rarely carry an offset, and FHIR times need one. A time with its own offset (20261021091500-0500) keeps it; any other gets the offset of timezone on that date, daylight saving included. A date stays a date, and a time without seconds gets :00.

The fhir step is a transform's last step. Rules before it change the HL7 message first, such as a map of MEDITECH's location codes, so the templates read the changed message.

What the Bundle looks like

Part of the Bundle for an ADT^A01, as the gateway sends it:

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:4675d2ae-d4a2-565f-8c1b-696c47f7ef37",
      "resource": {
        "resourceType": "Patient",
        "identifier": [ {
          "type": { "coding": [ { "system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "MR" } ] },
          "system": "urn:id:GENERAL_HOSPITAL",
          "value": "200301"
        } ],
        "name": [ { "use": "official", "family": "OKAFOR", "given": [ "AMARA", "N" ], "prefix": [ "MS" ] } ],
        "gender": "female",
        "birthDate": "1985-03-14"
      },
      "request": { "method": "PUT", "url": "Patient?identifier=urn%3Aid%3AGENERAL_HOSPITAL|200301" }
    },
    {
      "fullUrl": "urn:uuid:1b60df82-0a07-576b-bbb3-a4b2354213f5",
      "resource": {
        "resourceType": "Encounter",
        "identifier": [ { "type": { "coding": [ { "system": "http://terminology.hl7.org/CodeSystem/v2-0203", "code": "VN" } ] },
                          "system": "urn:id:GENERAL_HOSPITAL", "value": "V5001" } ],
        "status": "in-progress",
        "class": { "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", "code": "IMP", "display": "inpatient encounter" },
        "subject": { "reference": "urn:uuid:4675d2ae-d4a2-565f-8c1b-696c47f7ef37" },
        "period": { "start": "2026-10-21T08:25:00-04:00" }
      },
      "request": { "method": "PUT", "url": "Encounter?identifier=urn%3Aid%3AGENERAL_HOSPITAL|V5001" }
    },
    …
  ]
}

And an Observation from an ORU^R01:

{
  "fullUrl": "urn:uuid:ebb0fd6f-ae56-58aa-8264-40253c4219fb",
  "resource": {
    "resourceType": "Observation",
    "id": "ebb0fd6f-ae56-58aa-8264-40253c4219fb",
    "status": "final",
    "code": { "coding": [ { "system": "http://loinc.org", "code": "2345-7", "display": "Glucose" } ], "text": "Glucose" },
    "subject": { "reference": "urn:uuid:4675d2ae-d4a2-565f-8c1b-696c47f7ef37" },
    "effectiveDateTime": "2026-10-21T09:15:00-04:00",
    "valueQuantity": { "value": 102, "unit": "mg/dL", "system": "http://unitsofmeasure.org", "code": "mg/dL" },
    "interpretation": [ { "coding": [ { "system": "http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation", "code": "H" } ] } ],
    "referenceRange": [ { "text": "70-99" } ]
  },
  "request": { "method": "PUT", "url": "Observation/ebb0fd6f-ae56-58aa-8264-40253c4219fb" }
}
  • Identifiers get a system from the assigning authority: a code table (identifier_systems) first, then an OID as urn:oid:…, a URI as it is, or identifier_system_base and the authority (urn:id:GENERAL_HOSPITAL). Set identifier_system_base to your organization's own namespace.
  • Codes go through code tables: gender, patient class, statuses, interpretation, and coding systems (LN → http://loinc.org, SCT, I10, CVX, RXNORM, UCUM and more).
  • Empty values are left out, entries for the same resource are merged, every entry has a urn:uuid fullUrl, and a reference to something that isn't in the Bundle stops the message before it is sent.

Sending a message twice is safe

Messages get resent: a sender retries, an interface is replayed, a result is corrected. The Bundle is made so the server ends up with one copy of each thing:

ResourceHow the server finds it
Patient, EncounterADT updates them: PUT Patient?identifier=…. Results, orders and immunizations only create them if the server doesn't have them (ifNoneExist), so they never overwrite what ADT keeps.
DiagnosticReport, ServiceRequest, ImmunizationBy the filler's or placer's number: an update if the server has it, a create if not. ORU creates an order it hasn't seen; ORM and OML update it.
Observation, Specimen, AllergyIntolerance, Condition, Coverage, RelatedPersonA stable id, a UUID v5 from the identifiers that make it unique: PUT Observation/<id> replaces it.
PractitionerCreated once per identifier.

The fhir destination

type = "fhir" with the server's base_url. Each message is one POST of the Bundle to the base URL, as application/fhir+json, and the answer decides, entry by entry:

The server answersThe gateway
2xx, and every entry 2xxDelivered.
4xx for the Bundle, or for one of its entriesRejects the message: dead letter, or block the queue, as on_reject says; AR in pass-through. The reason has the server's OperationOutcome.
401, 403, 408, 409, 429, 5xx, or no answerRetries, with backoff. 401 first gets a new access token.
entry 2 (Observation) HTTP 400: error: Observation.status: Unknown ObservationStatus code 'done'

A fhir destination needs a transform that makes a FHIR Bundle; the gateway won't start with one that makes HL7. hl7edge_fhir_entries_total counts the entries servers took, by route, destination and resource.

Signing in

# Azure Health Data Services: the VM's, App Service's or Container App's managed identity
auth = { type = "managed_identity" }                       # client_id = "…" for a user-assigned identity
auth = { type = "workload_identity" }                      # from AKS; reads AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_FEDERATED_TOKEN_FILE
# The token's resource is base_url's origin; set resource = "…" for another.

# Google Cloud Healthcare API: a service account key file, or (without key_file) the metadata server
auth = { type = "google", key_file = "keys/fhir-writer.json" }

# SMART backend services, Epic, Oracle Health, Smile and others: OAuth2 client credentials
auth = { type = "oauth2", token_url = "https://fhir.example.org/oauth2/token", client_id = "hl7-edge-gateway",
         private_key = "keys/smart.pem", algorithm = "RS384", scope = "system/*.write" }

# A static token or API key
headers = { Authorization = "Bearer ${FHIR_TOKEN}" }

Tokens are cached and renewed before they expire. On Azure, give the identity the FHIR Data Contributor role on the FHIR service; on Google Cloud, Healthcare FHIR Resource Editor on the FHIR store.

Change the templates

The templates are Liquid, the same language as transform templates, and you can change any of them. Export them, change what you need, and point the step at the folder: its templates replace the built-in ones with the same name, and the rest stay built in.

$ hl7-edge-gateway fhir-templates my-fhir
28 files written to my-fhir

$ ls my-fhir
ADT.liquid                  _DiagnosticReport.liquid  _Practitioner.liquid
OML_O21.liquid              _Encounter.liquid         _PractitionerId.liquid
ORM_O01.liquid              _HumanName.liquid         _RelatedPerson.liquid
ORU_R01.liquid              _Identifier.liquid        _ServiceRequest.liquid
VXU_V04.liquid              _Immunization.liquid      _Specimen.liquid
_Address.liquid             _Observation.liquid       _SpecimenId.liquid
_AllergyIntolerance.liquid  _ObservationId.liquid     _Telecom.liquid
_CodeableConcept.liquid     _ObservationValue.liquid  tables.toml
_Condition.liquid           _OrderIds.liquid
_Coverage.liquid            _Patient.liquid

Templates that start with _ are partials, one per resource or data type (_Patient.liquid, _CodeableConcept.liquid); a new top-level template, such as SIU_S12.liquid, adds a message type.tables.toml in the folder adds codes to the built-in tables or changes them, entry by entry, and the transform's own [tables] do the same over both; a table given as a CSV file replaces the built-in one.

# my-fhir/tables.toml: only the codes you add or change; the other built-in entries stay
[patient_class]
RCR = "AMB"       # MEDITECH's recurring patients (dialysis, infusions)
SDC = "AMB"       # same-day care

[coding_system]
"99LAB" = "https://hospital.example/codes/lab"

Besides the transform template filters, FHIR templates have:

{%- assign obx = … -%}
{{ obx | field: "OBX-14" | fhir_datetime | json }}              # "2026-10-21T09:15:00-04:00"
{{ pid | field: "PID-7" | fhir_date | json }}                   # "1985-03-14"
{{ obx | field: "OBX-5" | fhir_decimal }}                       # 0.5 from ".5"; "" if it isn't a number
{%- assign ids = pid | repeats: "PID-3" -%}                    # each repetition, to loop over
{{ ids[0] | component: "4.1" }}                                 # a component (and subcomponent) of one
{{ "Observation" | fhir_id: report_id, obx_index }}             # a stable UUID v5 from the keys
{{ authority | fhir_system: universal_id, id_type | json }}      # an identifier system: a table, an OID, a URI, or the base

Try a change with hl7-edge-gateway transform -c gateway.toml --destination fhir message.hl7, or the console's Transform tester: both print the Bundle without sending it.

In Kubernetes

Put changed templates and tables.toml in the Helm chart's transformFiles, which sit next to the configuration, and set templates = ".".

transformFiles:
  ORU_R01.liquid: |
    …your changed template…
  tables.toml: |
    [patient_class]
    R = "AMB"
routes:
  - name: to-fhir
    destinations:
      - name: fhir
        type: fhir
        base_url: "https://contoso-fhir.fhir.azurehealthcareapis.com"
        auth: { type: workload_identity }
        transform: { step: [ { fhir: { timezone: "America/Toronto", templates: "." } } ] }

Licensing

FHIR is free for test and debug messages (MSH-11 = T or D), with the Developer edition too. For production messages it is part of the paid license. See Licensing.