A route with format = "x12" carries X12 interchanges instead of HL7 messages, with the same queues, retries, dead letters, filters, transforms, console and cluster. A hospital's billing system sends claims to the clearinghouse through it, remittances and claim acknowledgments come back the same way, and registration checks eligibility in real time. X12 arrived in 0.8.0.
| Transaction sets | What for |
|---|---|
837P, 837I, 837D | Professional, institutional and dental claims |
835 | Payment and remittance advice |
277CA, 999, TA1 | Claim acknowledgment; implementation and interchange acknowledgments |
270/271, 276/277, 278 | Eligibility, claim status, and prior authorization requests and responses |
834, 820 | Benefit enrollment and premium payment |
An X12 route
X12 comes from HTTP sources (the interchange is the request body), SFTP sources (one interchange per file, or several back to back) and Service Bus, and goes to HTTP, SFTP, Service Bus and CAQH CORE destinations. MLLP and SOAP are for HL7, and the gateway says so when it starts.
[[route]]
name = "claims"
mode = "store_and_forward"
format = "x12" # the default is "hl7v2"
store_and_forward = { on_reject = "dead_letter" }
x12 = { ta1 = "requested", ack_999 = true, split = "interchange" }
# The billing system drops claim files; the answers go back next to them.
[[route.sources]]
type = "sftp"
host = "files.northside.example"
username = "edi"
private_key = "/etc/hl7-edge-gateway/edi.key"
known_hosts = "/etc/hl7-edge-gateway/known_hosts"
directory = "outbox/claims"
pattern = "*.x12" # one interchange per file, or several back to back
after = "move:outbox/claims/done"
ack_directory = "outbox/claims/acks" # <file>.ack.x12: the TA1s and 999s
error_directory = "outbox/claims/errors"
# Or over HTTPS: the interchange is the body, the 999 is the response.
[[route.sources]]
type = "http"
listen = "0.0.0.0:8443"
path = "/x12/claims"
tls = { cert = "/etc/hl7-edge-gateway/server.crt", key = "/etc/hl7-edge-gateway/server.key" }
# To the clearinghouse, in the gateway's own envelope.
[[route.destinations]]
name = "clearinghouse"
type = "sftp"
host = "sftp.clearinghouse.example"
username = "northside"
private_key = "/etc/hl7-edge-gateway/clearinghouse.key"
host_key = "SHA256:…"
directory = "inbound"
envelope = { sender = "ZZ:NORTHSIDE", receiver = "ZZ:ACMECLEAR", ack_requested = true }Checked, then acknowledged
Every interchange is checked before anything else happens to it:
- The envelope: the fixed-width ISA and its delimiters, dates and times, the interchange, group and transaction set control numbers, and the counts in IEA, GE and SE.
- Each transaction set against its definition: loops and their nesting, segment order and repeats, required segments and elements, element types and lengths, and the code lists the standard fixes.
The sender gets a 999 for each functional group, accepting each set, accepting it with errors, or rejecting it with the segment (IK3) and element (IK4) at fault, and a TA1 when ISA-14 asks for one or the interchange itself is refused. Only accepted transaction sets go on. A resent interchange (the same sender and ISA-13) is acknowledged and not sent twice.
ISA*00* *00* *ZZ*CLEARHOUSE *ZZ*CLINIC01 *261026*0317*^*00501*000000001*0*T*:~
TA1*000000101*261021*0830*A*000~
GS*FA*CLEARHOUSE*CLINIC01*20261026*0317*1*X*005010X231A1~
ST*999*0001*005010X231A1~
AK1*HC*101*005010X222A1~
AK2*837*0001*005010X222A1~
IK3*N3*13*2000B*2~
IK3*N4*14*2000B*2~
IK3*DMG*15*2000B*2~
IK3*NM1*16*2010BA*3~
IK5*R*5~
AK9*R*1*1*0~
SE*10*0001~
GE*1*1~
IEA*1*000000001~| Source | Where the answer goes |
|---|---|
| HTTP | The response, as application/edi-x12: 200, or 400 when everything was rejected (503 when a pass-through destination is down). |
| SFTP | A file in ack_directory per file read, <file>.ack.x12. Refused interchanges go to error_directory with the reason. |
| Service Bus | Accepted: completed. Rejected: dead-lettered with the reason. |
[route.x12]
ta1 = "requested" # when ISA-14 = 1 asks for one, and for refused interchanges (default)
# "always": for every interchange; "errors": only for refused ones
ack_999 = true # a 999 for each functional group (default)
split = "interchange" # each interchange goes on as one message (default)
# "transaction": each transaction set in its own copy of the envelope (store-and-forward)The implementation guides (TR3s) add situational rules, such as a segment required only when another has a certain value. They are not checked yet; they come set by set in later releases. Until then, your clearinghouse's 999 and 277CA are the word on those.
Check a file first
$ hl7-edge-gateway x12 claims.x12
claims.x12: 837P from CLINIC01 to CLEARHOUSE (ISA-13 000000101, ISA-15 T)
rejected: 837P 0001: N3 (2000B) at 13: unexpected segment (and 3 more)
set 0001 837P 005010X222A1: rejected
N3 (2000B) at 13: unexpected segment
N4 (2000B) at 14: unexpected segment
DMG (2000B) at 15: unexpected segment
NM1 (2010BA) at 16: required segment missing
$ echo $?
3
$ hl7-edge-gateway x12 --ack eligibility.x12 # also prints the TA1 and 999 the gateway would answer
$ hl7-edge-gateway x12 --json remittance.x12 # also prints the JSON an x12 = "json" step makesThe gateway's own envelope
Trading partners usually expect interchanges from you, not from each system behind you. A destination's envelope replaces the sender's ISA and GS with the gateway's, keeping the transaction sets as they are:
envelope = { sender = "ZZ:NORTHSIDE", # ISA-05:ISA-06
receiver = "ZZ:ACMECLEAR", # ISA-07:ISA-08
application_sender = "NORTHSIDE", # GS-02, the ISA-06 ID if unset
application_receiver = "ACME", # GS-03, the ISA-08 ID if unset
ack_requested = true } # ISA-14: ask the receiver for a TA1Control numbers come from a counter in the store, one per destination. They carry on after a restart, two nodes of a JetStream cluster never give out the same one, and a retry of a message keeps the numbers its first try had.
Filters
Paths name X12 segments and elements, CLM-2, CLM-5.1 (a component), NM1[2]-9 (an occurrence), and a loop narrows them: 2010BA/NM1-9 is the subscriber's ID only. Fields: transaction_set (837P, 277CA, or ST-01), sender and receiver (ISA-06, ISA-08), sending_application and receiving_application (GS-02, GS-03), and processing_id (ISA-15). With split = "transaction", a filter picks transaction sets rather than interchanges.
# Only professional claims for one payer
filter = { transaction_set = ["837P"], "2010BB/NM1-9" = ["ACME1"] }
# Remittances (835) and claim acknowledgments (277CA) to the revenue system; the rest elsewhere
filter = { transaction_set = ["835", "277CA"] }
# Replacement and void claims (claim frequency 7 or 8 in CLM-05-3)
filter = { "CLM-5.3" = ["7", "8"] }Transforms
Transform rules work on X12 segments as they do on HL7 fields, loop paths included. A new last step, x12 = "json", turns each transaction into JSON by its loops, for APIs that take remittances or claim status as JSON; a Liquid template can make X12 (output = "x12"), such as a 270 from a JSON request.
# Rules work on X12 segments as they do on HL7 fields. SE-01 is counted again.
[[step]]
set = { "1000A/PER-2" = "BILLING OFFICE" }
[[step]]
delete = { segment = "PER" }
# Last, an 835 as JSON for a revenue API.
[[step]]
x12 = "json"{
"interchange": { "sender": "ACME1", "receiver": "NORTHSIDE", "control_number": "000000417", "usage": "P", … },
"transactions": [ {
"id": "835", "kind": "835", "control_number": "0001", "guide": "005010X221A1",
"loops": {
"BPR": [ { "BPR01": "I", "BPR02": "1220.50", … } ],
"1000A": [ { "N1": [ { "N101": "PR", "N102": "ACME HEALTH PLAN" } ], … } ],
"2000": [ { "LX": [ { "LX01": "1" } ],
"2100": [ { "CLP": [ { "CLP01": "PAT1001", "CLP02": "1", "CLP03": "150", "CLP04": "120" } ], … } ] } ]
}
} ]
}Licensing and metrics
X12 is free for test interchanges (ISA-15 = T), with the Developer edition too. Production interchanges (P) need the paid license; a license that lists its add-ons must list x12. A refused interchange gets a TA1 that says why. hl7edge_x12_acks_total counts the TA1s and 999s the gateway sent, by route, kind and status.