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
| Message | Resources |
|---|---|
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^R01 | DiagnosticReport 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^O21 | ServiceRequest 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^V04 | Immunization 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 asurn:oid:…, a URI as it is, oridentifier_system_baseand the authority (urn:id:GENERAL_HOSPITAL). Setidentifier_system_baseto 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,UCUMand more). - Empty values are left out, entries for the same resource are merged, every entry has a
urn:uuidfullUrl, 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:
| Resource | How the server finds it |
|---|---|
| Patient, Encounter | ADT 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, Immunization | By 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, RelatedPerson | A stable id, a UUID v5 from the identifiers that make it unique: PUT Observation/<id> replaces it. |
| Practitioner | Created 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 answers | The gateway |
|---|---|
| 2xx, and every entry 2xx | Delivered. |
| 4xx for the Bundle, or for one of its entries | Rejects 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 answer | Retries, 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.liquidTemplates 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 baseTry 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.