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).
| Route | What it shows |
|---|---|
claims-to-clearinghouse | An 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-billing | split = "transaction": each set on its own, so filter = { transaction_set = … } sends 835s to the revenue API as JSON (x12 = "json") and 277CAs to billing. |
eligibility | Pass-through to a caqh_core destination: the payer's 271 straight back to registration. |
remits-from-payer | A 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.mmd
data-flow.mmd
sequence.mmdThe 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
- 1Start 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 -dThe files are test interchanges (ISA-15 =
T), so the free Developer license works. The console is athttps://localhost:8080; its certificate is self-signed, made by thekeyscontainer, so the browser warns once. Sign in asbilling-opswith the password you hashed. To look at the SFTP folders yourself, sign in tolocalhost:2222asbillingornorthsidewithSFTP_PASSWORD. - 2Check the claim files first.
The
x12command 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--ackadds the 999 the gateway would answer, and--jsonthe JSON a transform makes. - 3Billing drops the claims.
Put
files/1-claims.x12(two interchanges back to back) andfiles/2-claim-missing-subscriber.x12inbilling'soutbox. Within a few seconds, the answers are inoutbox/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.txtthat says why, and both files are inoutbox/done.
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. - 4The clearinghouse gets the accepted claims.
northside'sinboundfolder hasclaims_NSBILLING_000000101.x12and…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~ - 5A remittance and an acknowledgment come back.
Put
files/3-remittance-835.x12andfiles/4-claim-acknowledgment-277ca.x12innorthside'sreports. The revenue API gets the 835 as JSON, and the 277CA goes tobilling'sinbox:docker compose logs revenue revenue: EFT20261030 PAT1001 billed 150 paid 120 patient owes 30 revenue: EFT20261030 PAT1002 billed 95 paid 72 patient owes 0The console's Filter tester shows which destination gets which transaction set:

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:

The x12 = "json" step: the 835 by its loops. - 6Eligibility, 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.x12the answer isAAA*Y**75*C~, subscriber not found. The gateway sent each 270 to the payer as a SOAP 1.2COREEnvelopeRealTimeRequestwith the 270 as an MTOM attachment;hl7edge_core_requests_totalcounts the answers by ErrorCode. Without the API key, the gateway answers 401. - 7Remittances from the payer.
Every 10 seconds the
remits-from-payerroute asks the payer's CORE server for 835s (COREEnvelopeBatchResultsRetrievalRequest, PayloadTypeX12_835_Request_005010X221A1). The payer has one waiting, inpayer/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 40The 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_totalcounts each. - 8The console and the audit trail.
Signed in as
billing-ops, pause and resume a route: it works. Opening a message doesn't (noview_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 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.