Skip to content
LambogeniusDocs

HL7 Edge Gateway

X12 EDI

Claims, remittances, eligibility and enrollment (HIPAA 5010) through the same routes, checked and acknowledged with TA1 and 999.

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

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 setsWhat for
837P, 837I, 837DProfessional, institutional and dental claims
835Payment and remittance advice
277CA, 999, TA1Claim acknowledgment; implementation and interchange acknowledgments
270/271, 276/277, 278Eligibility, claim status, and prior authorization requests and responses
834, 820Benefit 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~
SourceWhere the answer goes
HTTPThe response, as application/edi-x12: 200, or 400 when everything was rejected (503 when a pass-through destination is down).
SFTPA file in ack_directory per file read, <file>.ack.x12. Refused interchanges go to error_directory with the reason.
Service BusAccepted: 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)
Situational rules

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 makes

The 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 TA1

Control 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.