Skip to content
LambogeniusDocs

HL7 Edge Gateway

Claims, remittances and eligibility

X12: claims from billing checked and acknowledged, sent to the clearinghouse in the clinic's own envelope; remittances to a revenue API as JSON; real-time eligibility with a payer's CAQH CORE server; an encrypted store, console sign-in and the audit trail.

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

A clinic's billing system makes 837 claims. The clearinghouse wants them from the clinic, checked, in the clinic's own envelope, and sends 835 remittances and 277CA claim acknowledgments back. Registration wants to know, while the patient is at the desk, whether the health plan covers them: a 270 to the payer's CAQH CORE server, a 271 back. The payer also has remittances of its own waiting on that server, for the gateway to pick up. Four X12 routes do it all. Claims and remittances are PHI, so stored messages are encrypted, the console is HTTPS with a sign-in for the billing office's operator, and everything done in it is in the audit trail. You need the gateway image 0.9.2 or later (0.9.0 works, but its health check reports the TLS console as unhealthy).

RouteWhat it shows
claims-to-clearinghouseAn SFTP source reading X12 files; every interchange checked and answered with a 999 (and a TA1, as ISA-14 asks) in ack_directory; a rejected interchange to error_directory with the reason; accepted claims sent on under the gateway's own ISA/GS (envelope).
reports-to-billingsplit = "transaction": each set on its own, so filter = { transaction_set = … } sends 835s to the revenue API as JSON (x12 = "json") and 277CAs to billing.
eligibilityPass-through to a caqh_core destination: the payer's 271 straight back to registration.
remits-from-payerA caqh_core source: asks the payer's CORE server for 835s waiting there every poll_interval, sends each to the revenue API as JSON, and sends the payer the gateway's 999 for it.

And around them: [store] encryption (AES-256-GCM, the key from HL7EDGE_STORE_KEY), the console over HTTPS with the local user billing-ops (an operator: pause, retry and requeue, but no message contents and no audit trail), automatic logoff after 15 minutes, and the audit trail.

One SFTP server stands in for both the clinic's file server (account billing) and the clearinghouse's (account northside); small Python services stand in for the revenue API and the payer's CORE server. The people, organizations and IDs are made up.

The sample is in 15-claims-remits-eligibility in the examples repository.

Diagrams

Architecture diagram of the 15-claims-remits-eligibility sample
Architecture. What runs where, and how the systems connect. Source: architecture.mmd
Data flow diagram of the 15-claims-remits-eligibility sample
Data flow. What happens to a message at each step, including the failure paths. Source: data-flow.mmd
Sequence diagram of the 15-claims-remits-eligibility sample
Sequence. One run of the sample, message by message. Source: sequence.mmd

The configuration

15-claims-remits-eligibility/gateway.toml — four X12 routes: SFTP in and out, HTTP to a revenue API, and a CAQH CORE payer both ways; the console user, the audit trail and store encryption

# Sample 15: claims, remittances and eligibility in X12 (HL7 Edge Gateway 0.9+).
#
#   claims-to-clearinghouse  The billing system drops 837 claim files in its SFTP folder outbox. The gateway checks
#                            each interchange, writes its 999 (and TA1) to outbox/acks for billing, and sends each
#                            accepted claim to the clearinghouse's folder inbound, in the gateway's own envelope.
#                            A rejected interchange goes to outbox/errors with the reason.
#   reports-to-billing       The clearinghouse drops remittances (835) and claim acknowledgments (277CA) in reports.
#                            Each transaction set goes on its own: 835s to the revenue API as JSON, 277CAs to the
#                            billing system's folder inbox.
#   eligibility              Registration posts a 270 over HTTP and gets the payer's 271 back, through the payer's
#                            CAQH CORE server (connectivity rule C4.0.0), in real time.
#   remits-from-payer        The gateway asks the payer's CORE server for remittances (835) waiting there, sends each
#                            to the revenue API as JSON, and sends the payer its 999 for each, as CORE asks.
#
# One SFTP server stands in for both the hospital's file server (account billing) and the clearinghouse's
# (account northside). The gateway signs in with the key the keys container makes. Secrets come from .env.
#
# The data is PHI, so: stored messages are encrypted (HL7EDGE_STORE_KEY), the console is HTTPS with a sign-in for
# billing-ops (an operator, who can't see message contents), and everything done in it is in the audit trail.

node_id = "gateway-1"

[admin]
listen = "0.0.0.0:8080"
# The keys container makes a self-signed certificate for localhost, so the browser warns once. In production, use
# your own certificate, or end TLS at a load balancer and set behind_tls_proxy = true.
tls = { cert = "/keys/console.crt", key = "/keys/console.key" }
token = "${HL7EDGE_ADMIN_TOKEN}"     # an admin API token, for demo.sh and scripts
allow_payload_view = true            # the message viewer, for those with view_payload (the admin token has it)

# The billing office's operator signs in to the console with a password: pause, retry and requeue, but no message
# contents. Make the hash with `hl7-edge-gateway hash-password` (see .env.example).
[[admin.users]]
name = "billing-ops"
password_hash = "${BILLING_OPS_PASSWORD_HASH}"
role = "operator"
view_payload = false

# Single sign-on instead, with Entra ID or another OpenID Connect provider:
# [admin.oidc]
# issuer = "https://login.microsoftonline.com/<tenant id>/v2.0"
# client_id = "<application id>"
# client_secret = "${OIDC_CLIENT_SECRET}"
# public_url = "https://localhost:8080"
# role_map = { "Billing.Operator" = "operator", "Billing.Auditor" = "auditor", "Billing.Admin" = "admin" }

[admin.session]
idle_timeout = "15m"                 # automatic logoff

[audit]
retention = "6y"                     # sign-ins, changes, message views; GET /api/audit, or the console's Audit card

[store]
backend = "embedded"
path = "/var/lib/hl7-edge-gateway"
# Claims and remittances are encrypted (AES-256-GCM) before they're written. Make the key with
# `hl7-edge-gateway generate-key`; keep it somewhere other than the queue volume.
encryption = { keys = [{ id = "sample-15", env = "HL7EDGE_STORE_KEY" }] }

[[route]]
name = "claims-to-clearinghouse"
mode = "store_and_forward"           # billing gets its 999 once a claim is stored; the clearinghouse can be down
format = "x12"
x12 = { ta1 = "requested" }          # a TA1 when ISA-14 = 1 asks for one, as billing's files do
store_and_forward = { on_reject = "dead_letter", retry = { initial = "2s", max = "30s" } }

[route.source]
type = "sftp"
host = "sftp"
username = "billing"
private_key = "/keys/gateway_ed25519"
known_hosts = "/keys/known_hosts"
directory = "outbox"
pattern = "*.x12"                    # one interchange per file, or several back to back
poll_interval = "3s"
after = "move:outbox/done"
ack_directory = "outbox/acks"        # <file>.ack.x12: a TA1 and a 999 for each interchange
error_directory = "outbox/errors"    # rejected interchanges, and <file>.txt with the reasons

[[route.destinations]]
name = "clearinghouse"
type = "sftp"
host = "sftp"
username = "northside"
private_key = "/keys/gateway_ed25519"
known_hosts = "/keys/known_hosts"
directory = "inbound"
file_name = "claims_{control_id}.x12"
# The clearinghouse knows the hospital as NORTHSIDE, not as the billing system's own ID.
envelope = { sender = "ZZ:NORTHSIDE", receiver = "ZZ:ACMECLEAR" }

[[route]]
name = "reports-to-billing"
mode = "store_and_forward"
format = "x12"
x12 = { split = "transaction" }      # each transaction set on its own, so the filters pick 835s and 277CAs
store_and_forward = { on_reject = "dead_letter", retry = { initial = "2s", max = "30s" } }

[route.source]
type = "sftp"
host = "sftp"
username = "northside"
private_key = "/keys/gateway_ed25519"
known_hosts = "/keys/known_hosts"
directory = "reports"
pattern = "*.x12"
poll_interval = "3s"
after = "move:reports/processed"

[[route.destinations]]
name = "revenue-api"
type = "http"
url = "http://revenue:8000/remits"
filter = { transaction_set = ["835"] }
transform = { step = [ { x12 = "json" } ] }   # the remittance as JSON, by its loops

[[route.destinations]]
name = "billing-inbox"
type = "sftp"
host = "sftp"
username = "billing"
private_key = "/keys/gateway_ed25519"
known_hosts = "/keys/known_hosts"
directory = "inbox"
filter = { transaction_set = ["277CA"] }

[[route]]
name = "eligibility"
mode = "passthrough"                 # registration waits for the payer's answer
format = "x12"
request_timeout = "20s"
passthrough = { ack_from = "response_body" }  # answer with the payer's 271 as it came

[route.source]
type = "http"
listen = "0.0.0.0:8081"
path = "/eligibility"
auth = { type = "api_key", keys = { registration = "${REGISTRATION_API_KEY}" } }

[[route.destinations]]
name = "acme-health-plan"
type = "caqh_core"
url = "http://payer:8000/core"
sender_id = "NORTHSIDE"              # SenderID and ReceiverID, as the payer's connectivity guide gives them
receiver_id = "ACME1"
# A real payer's CORE server takes HTTPS with a client certificate, and often an OAuth 2.0 token:
# url = "https://core.acmehealth.example/CORE/RealTime"
# tls = { client_cert = "/etc/hl7-edge-gateway/core-client.crt", client_key = "/etc/hl7-edge-gateway/core-client.key" }
# auth = { type = "oauth2", token_url = "https://login.acmehealth.example/oauth2/token", client_id = "northside", client_secret = "${ACME_CORE_SECRET}" }

[[route]]
name = "remits-from-payer"
mode = "store_and_forward"           # each remittance is stored before the payer gets its 999
format = "x12"
store_and_forward = { on_reject = "dead_letter", retry = { initial = "2s", max = "30s" } }

[route.source]
type = "caqh_core"
url = "http://payer:8000/core"
sender_id = "NORTHSIDE"
receiver_id = "ACME1"
retrieve = ["X12_835_Request_005010X221A1"]   # the PayloadTypes the payer's connectivity guide lists
poll_interval = "10s"                # every few minutes or hours with a real payer

[[route.destinations]]
name = "revenue-api"
type = "http"
url = "http://revenue:8000/remits"
transform = { step = [ { x12 = "json" } ] }

15-claims-remits-eligibility/compose.yaml — the gateway, the SFTP server and its keys, the revenue API, the payer, and the tool command

# Sample 15: claims to the clearinghouse, remittances back, and real-time eligibility, all in X12.
#   docker compose up -d                  keys, the SFTP server, the gateway, a revenue API and a payer's CORE server
#   docker compose run --rm tool …        gateway commands, such as x12, on the files in files/
name: hl7edge-sample-15

services:
  gateway:
    image: lambogenius/hl7-edge-gateway:${GATEWAY_VERSION:-0.9.2}
    env_file: .env
    ports:
      - "8081:8081"   # eligibility requests from registration (HTTP)
      - "8080:8080"   # admin console, https://localhost:8080
    volumes:
      - ./gateway.toml:/etc/hl7-edge-gateway/hl7-edge-gateway.toml:ro
      - keys:/keys:ro
      - queue:/var/lib/hl7-edge-gateway
    depends_on:
      sftp: { condition: service_healthy }   # sshd is listening, so the first poll doesn't fail
      revenue: { condition: service_started }
      payer: { condition: service_started }

  # The SFTP server, standing in for two: the hospital's file server, where the billing system's account
  # "billing" has outbox (claims, with done, acks and errors) and inbox (claim acknowledgments), and the
  # clearinghouse's, where the hospital's account "northside" has inbound (claims) and reports (remittances
  # and acknowledgments, with processed). SFTP_PASSWORD signs you in to either, on localhost:2222.
  sftp:
    image: atmoz/sftp:alpine
    command:
      - "billing:${SFTP_PASSWORD}:1001:100:outbox,outbox/done,outbox/acks,outbox/errors,inbox"
      - "northside:${SFTP_PASSWORD}:1002:100:inbound,reports,reports/processed"
    ports: ["2222:22"]
    volumes:
      - keys:/keys:ro
      - ./sftp/install-keys.sh:/etc/sftp.d/install-keys.sh:ro
    healthcheck:
      test: ["CMD", "nc", "-z", "127.0.0.1", "22"]
      interval: 2s
      retries: 15
    depends_on:
      keys: { condition: service_completed_successfully }

  # Makes the SFTP server's host keys, the gateway's key and its known_hosts, once.
  keys:
    image: alpine:3.20
    command: ["sh", "/scripts/make-keys.sh"]
    volumes:
      - keys:/keys
      - ./sftp:/scripts:ro

  # Stand-in for the hospital's revenue-cycle API: takes remittances as JSON.
  revenue:
    image: python:3.12-slim
    command: ["python", "-u", "/app/revenue_api.py"]
    volumes: ["./revenue:/app:ro"]

  # Stand-in for the health plan's CAQH CORE server: answers 270s with 271s, and hands out the remittances in
  # payer/remits.
  payer:
    image: python:3.12-slim
    command: ["python", "-u", "/app/core_payer.py"]
    volumes: ["./payer:/app:ro"]

  # Tools, not started by `up`.
  tool:
    image: lambogenius/hl7-edge-gateway:${GATEWAY_VERSION:-0.9.2}
    entrypoint: ["hl7-edge-gateway"]
    env_file: .env
    working_dir: /sample
    volumes: [".:/sample:ro", "./files:/files:ro"]
    profiles: ["tools"]

volumes:
  keys:
  queue:

Step by step

  1. 1
    Start it.
    cd 15-claims-remits-eligibility
    cp .env.example .env            # add your license
    docker run --rm lambogenius/hl7-edge-gateway:0.9.2 generate-key          # into HL7EDGE_STORE_KEY
    docker run --rm -it lambogenius/hl7-edge-gateway:0.9.2 hash-password     # into BILLING_OPS_PASSWORD_HASH, in '…'
    docker compose up -d

    The files are test interchanges (ISA-15 = T), so the free Developer license works. The console is at https://localhost:8080; its certificate is self-signed, made by the keys container, so the browser warns once. Sign in as billing-ops with the password you hashed. To look at the SFTP folders yourself, sign in to localhost:2222 as billing or northside with SFTP_PASSWORD.

  2. 2
    Check the claim files first.

    The x12 command checks files as the gateway will, and exits 3 when something is rejected:

    docker compose run --rm tool x12 /files/1-claims.x12 /files/2-claim-missing-subscriber.x12
    /files/1-claims.x12 #1: 837P from NSBILLING to ACMECLEAR (ISA-13 000000101, ISA-15 T)
      accepted: 1 transaction set
      set 0001 837P 005010X222A1: accepted
    /files/1-claims.x12 #2: 837P from NSBILLING to ACMECLEAR (ISA-13 000000102, ISA-15 T)
      …
    /files/2-claim-missing-subscriber.x12: 837P from NSBILLING to ACMECLEAR (ISA-13 000000103, 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

    --ack adds the 999 the gateway would answer, and --json the JSON a transform makes.

  3. 3
    Billing drops the claims.

    Put files/1-claims.x12 (two interchanges back to back) and files/2-claim-missing-subscriber.x12 in billing's outbox. Within a few seconds, the answers are in outbox/acks, one file for each file read:

    outbox/acks/1-claims.x12.ack.x12
    ISA*00*          *00*          *ZZ*ACMECLEAR      *ZZ*NSBILLING      *261026*0337*^*00501*000000001*0*T*:~
    TA1*000000101*261026*0800*A*000~
    GS*FA*ACMECLEAR*NSBILLING*20261026*0337*1*X*005010X231A1~
    ST*999*0001*005010X231A1~
    AK1*HC*101*005010X222A1~
    AK2*837*0001*005010X222A1~
    IK5*A~
    AK9*A*1*1*1~
    …                                   (and the same for 000000102)
    
    outbox/acks/2-claim-missing-subscriber.x12.ack.x12
    …
    IK3*NM1*16*2010BA*3~                the subscriber's name (loop 2010BA) is missing
    IK5*R*5~
    AK9*R*1*1*0~

    The rejected interchange is in outbox/errors, with a .txt that says why, and both files are in outbox/done.

    The console signed in as billing-ops (operator) with four routes, each marked X12: claims-to-clearinghouse (SFTP billing outbox to SFTP northside inbound), reports-to-billing (to the revenue API and billing's inbox), eligibility (pass-through, HTTP to CAQH CORE C4.0.0, no Pause button) and remits-from-payer (CAQH CORE to the revenue API)
    The console after every step, signed in as billing-ops: an operator may pause store-and-forward routes. The X12 badge shows the TA1s and 999s each route sent.
  4. 4
    The clearinghouse gets the accepted claims.

    northside's inbound folder has claims_NSBILLING_000000101.x12 and …102.x12. The billing system's own ID became the clinic's, with control numbers the gateway keeps and never repeats:

    ISA*00*          *00*          *ZZ*NORTHSIDE      *ZZ*ACMECLEAR      *261026*0338*^*00501*000000001*0*T*:~
    GS*HC*NORTHSIDE*ACMECLEAR*20261026*0338*2*X*005010X222A1~
    ST*837*0001*005010X222A1~
    …
    CLM*PAT1001*150***11:B:1*Y*A*Y*Y~
  5. 5
    A remittance and an acknowledgment come back.

    Put files/3-remittance-835.x12 and files/4-claim-acknowledgment-277ca.x12 in northside's reports. The revenue API gets the 835 as JSON, and the 277CA goes to billing's inbox:

    docker compose logs revenue
    revenue: EFT20261030 PAT1001 billed 150 paid 120 patient owes 30
    revenue: EFT20261030 PAT1002 billed 95 paid 72 patient owes 0

    The console's Filter tester shows which destination gets which transaction set:

    Filter tester with route reports-to-billing and the 835 and 277CA pasted: the 835 goes to revenue-api only, the 277CA to billing-inbox only
    The Filter tester: each transaction set, and the destination its transaction_set filter picks.

    And the Transform tester shows the JSON the revenue API gets:

    Transform tester with destination reports-to-billing → revenue-api and the 835 pasted: JSON with the interchange's sender, receiver and control number, then the 835 transaction and its loops
    The x12 = "json" step: the 835 by its loops.
  6. 6
    Eligibility, in real time.
    curl -H "X-API-Key: <REGISTRATION_API_KEY>" -H "Content-Type: application/edi-x12" \
      --data-binary @files/5-eligibility-270.x12 http://localhost:8081/eligibility
    ISA*00*          *00*          *ZZ*ACME1          *ZZ*NORTHSIDE      *…
    ST*271*0001*005010X279A1~
    …
    NM1*IL*1*RIVERA*ANA****MI*W100200300~
    EB*1*IND*30**GOLD PLAN~
    EB*C*IND*30***23*500~
    EB*B*IND*98***27*25~
    MSG*COPAY APPLIES TO OFFICE VISITS~

    The payer's 271: covered, a $500 deductible and a $25 copay. For files/6-eligibility-270-not-covered.x12 the answer is AAA*Y**75*C~, subscriber not found. The gateway sent each 270 to the payer as a SOAP 1.2 COREEnvelopeRealTimeRequest with the 270 as an MTOM attachment; hl7edge_core_requests_total counts the answers by ErrorCode. Without the API key, the gateway answers 401.

  7. 7
    Remittances from the payer.

    Every 10 seconds the remits-from-payer route asks the payer's CORE server for 835s (COREEnvelopeBatchResultsRetrievalRequest, PayloadType X12_835_Request_005010X221A1). The payer has one waiting, in payer/remits/:

    docker compose logs payer revenue
    payer: remittance 835-acme-007501.x12 handed out
    payer: 999 received for a remittance: AK9*A*1*1*1
    revenue: EFT20261106 PAT1003 billed 200 paid 160 patient owes 40

    The gateway stored it, sent the payer its 999 (as CORE asks), and delivered it to the revenue API as JSON; the next poll gets X12_005010_Response_NoBatchResultsFile. hl7edge_core_retrievals_total counts each.

  8. 8
    The console and the audit trail.

    Signed in as billing-ops, pause and resume a route: it works. Opening a message doesn't (no view_payload), and neither does the audit trail (auditors and admins only); both refusals are in the trail. With the admin token:

    curl -k -H "Authorization: Bearer <HL7EDGE_ADMIN_TOKEN>" "https://localhost:8080/api/audit?limit=10"

    lists the sign-in, the pause and resume, the refusals, and the gateway's start, newest first, each with who, from where and the outcome. The gateway's log says stored messages are encrypted: what waits in the queue volume is ciphertext. See Security and HIPAA.

A real payer

A payer's CORE server takes HTTPS with a client certificate, and often an OAuth 2.0 token. Set url, sender_id and receiver_id from its connectivity guide, and tls and auth from your enrollment; the lines are in gateway.toml. Set retrieve to the PayloadTypes its guide lists. See CAQH CORE.

Or run every step with ./demo.sh (Linux, macOS, Git Bash) or ./demo.ps1 (PowerShell); set BILLING_OPS_PASSWORD in .env too for them to sign in as billing-ops. Clean up with docker compose down -v. More in X12 EDI.