HIPAA 835 remittance posting
A health insurer pays a hospital for its patient claims. Once per payment cycle, the insurer (or the clearinghouse acting for it) sends a remittance file that lists every claim: which were paid in full, which were paid less than billed, and which were turned down. The hospital’s billing team has to record each of those outcomes against the matching claim. Denied claims need a second pair of eyes, so they land on a follow-up worklist as well. The whole thing names real patients and real money, so it has to be handled with care and leave a clean trail of who did what. Doing it by hand is days of work each cycle. This flow does it automatically.
So the picture is simple. The insurer drops one remittance file. The hospital fans it out into one record per claim, posts each to its billing system, and pushes the denied ones onto a worklist for a person to chase.
That remittance file is a HIPAA X12 835, the electronic remittance advice (ERA), and structurally this is the settlement debatch in a healthcare setting, with a custom disassembler doing the segment-level reading. Here is how Art2link ESB builds it.
Everything in Art2link rides a shared message backbone called the bus: parts of the flow publish messages onto it, and other parts subscribe to the ones they care about, so the pieces never call each other directly. A Scheduler (an adapter, a connector to one kind of outside system, here a clock) ticks on a schedule and drives a two-way SFTP Caller send port, a configured endpoint that reaches out and downloads the file from the payer’s (or clearinghouse’s) mailbox. On the way in, the Hipaa835Disassembler, a pipeline component (a small piece of custom code that reshapes a message in transit), walks the CLP loops, one per claim payment, and publishes each onto the bus as a canonical ClaimPayment: claim number, billed versus paid amounts, the adjustment group and reason codes (CAS segments) that explain every shortfall. A SQL Caller send port posts each payment against the billing database. Denials route themselves to a worklist by subscription (the filter a port uses to pick up only the messages it wants), not by code. To make that filter cheap, the disassembler promotes the claim’s status, lifting it out of the message body into a named token a subscription can read without parsing the payload:
{{Message.MessageType}} == "ClaimPayment"
{{Promoted.ClaimPayment.Status}} == "Denied"
This is PHI. An 835 ties patients to claims and payments, which makes the flow itself part of your HIPAA story. Art2link’s side of it: the SFTP Authentication pins the payer’s host key, payloads and runs are visible only inside the platform, and the three-stream logs give you the compliance-grade evidence trail, who configured what, what ran when, what failed and why. Keep the operations alert lean for the same reason: claim numbers and amounts, never patient names, in the email body.
When it fails. The shared exception type RemitPostFailed covers the fetch and both posting ports, with the usual O365 Mail subscription. A claim payment that matches nothing in billing, wrong claim number, already-posted cycle, fails alone and waits in tracking, exactly the per-record isolation the settlement example establishes; an ERA file the disassembler cannot parse suspends whole as a poison message, covered by Activity Notifications (On Error Only).
{{Message.MessageType}} == "RemitPostFailed"
Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.
Under the application’s Message types, create the two types this flow routes on. A message type is a name plus a format, no schema required:
| Name | Format | Purpose |
|---|---|---|
| ClaimPayment | JSON | one claim payment per CLP loop, as the disassembler (Step 4) publishes it |
| RemitPostFailed | JSON | the exception type the failure path publishes under (Step 9) |
Promotions live on the message type, so add them while you are here. The denial worklist routes on Status, the posting parameters (Steps 7 and 8) and the alert subject (Step 9) bind the rest, and adapter parameters are plain strings: they bind {{…}} tokens but never evaluate a body path.
| Promotion | Path |
|---|---|
| Status | $.status |
| ClaimNumber | $.claimNumber |
| TraceNumber | $.traceNumber |
| Billed | $.billed |
| Paid | $.paid |
| PatientResp | $.patientResponsibility |
| RemitDate | $.remitDate |
A failed posting re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:
| Promotion | Path |
|---|---|
| ClaimNumber | $.claimNumber |
Three external parties, three credentials. Under the application’s Authentications, the dialog asks for a Name, the Adapter it pairs with, and a Definition, the credential shape that adapter offers, with the Application preset; the Definition decides the config section that follows.
| Setting | Value |
|---|---|
| Name | PayerSftp |
| Adapter | SFTP Caller |
| Definition | SFTP Password Authentication |
| Username / Password | the ERA mailbox credentials |
| Host Key Fingerprint | the payer’s pinned host key |
| Setting | Value |
|---|---|
| Name | BillingDbSql |
| Adapter | SQL Caller |
| Definition | SQL Server Connection |
| Connection String (Database Config) | the billing database’s connection string, credentials included |
| Setting | Value |
|---|---|
| Name | O365OpsMail |
| Adapter | O365 Mail Sender |
| Definition | Microsoft Graph |
| Tenant Id / Client Id / Client Secret (Graph AuthConfig) | an app registration with Mail.Send granted |
Create the billing-side objects; the posting ports (Steps 7 and 8) call the procedures. dbo.PostRemit is idempotent on claim + trace number, so a re-published ERA converges instead of paying twice; dbo.InsertDenial backs the worklist.
CREATE TABLE dbo.ClaimPayments ( ClaimNumber VARCHAR(40) NOT NULL, TraceNumber VARCHAR(40) NOT NULL, Billed DECIMAL(18,2) NULL, Paid DECIMAL(18,2) NULL, PatientResp DECIMAL(18,2) NULL, Status VARCHAR(20) NULL, RemitDate DATE NULL, PostedAt DATETIME2 NOT NULL CONSTRAINT DF_CP DEFAULT SYSUTCDATETIME(), CONSTRAINT PK_ClaimPayments PRIMARY KEY (ClaimNumber, TraceNumber) ); CREATE OR ALTER PROCEDURE dbo.PostRemit @ClaimNumber VARCHAR(40), @TraceNumber VARCHAR(40), @Billed DECIMAL(18,2), @Paid DECIMAL(18,2), @PatientResp DECIMAL(18,2), @Status VARCHAR(20), @RemitDate DATE AS BEGIN SET NOCOUNT ON; MERGE dbo.ClaimPayments AS tgt USING (SELECT @ClaimNumber AS ClaimNumber, @TraceNumber AS TraceNumber) AS src ON tgt.ClaimNumber = src.ClaimNumber AND tgt.TraceNumber = src.TraceNumber WHEN NOT MATCHED THEN INSERT (ClaimNumber, TraceNumber, Billed, Paid, PatientResp, Status, RemitDate) VALUES (@ClaimNumber, @TraceNumber, @Billed, @Paid, @PatientResp, @Status, @RemitDate); END; CREATE TABLE dbo.DenialWorklist ( Id INT IDENTITY NOT NULL PRIMARY KEY, ClaimNumber VARCHAR(40) NOT NULL, TraceNumber VARCHAR(40) NOT NULL, CreatedAt DATETIME2 NOT NULL CONSTRAINT DF_DW DEFAULT SYSUTCDATETIME() ); CREATE OR ALTER PROCEDURE dbo.InsertDenial @ClaimNumber VARCHAR(40), @TraceNumber VARCHAR(40) AS BEGIN SET NOCOUNT ON; IF NOT EXISTS (SELECT 1 FROM dbo.DenialWorklist WHERE ClaimNumber = @ClaimNumber AND TraceNumber = @TraceNumber) INSERT INTO dbo.DenialWorklist (ClaimNumber, TraceNumber) VALUES (@ClaimNumber, @TraceNumber); END;
In Pipeline components, create Hipaa835Disassembler, the AI assistant’s guided mode fits: inbound shape is the 835 below, the boundary is the CLP loop, the output one ClaimPayment JSON per loop (Message Type set on the outgoing message), carrying the TRN trace number and the CAS adjustments. Handle the edge case of an 835 with zero CLP loops (a header-only remit) by publishing nothing and logging it.
ST*835*0001~ BPR*I*1620.00*C*ACH*CCP*01*021000021*DA*555111222*1512345678**01*071000013*DA*987654321*20260604~ TRN*1*EFT-204412*1512345678~ CLP*CLM-1011*1*1250.00*1000.00*250.00*MC*ICN-884201*11~ CAS*CO*45*250.00~ NM1*QC*1*DOE*JANE~ CLP*CLM-1012*2*900.00*620.00*80.00*MC*ICN-884202*11~ CAS*CO*45*200.00~ NM1*QC*1*ROE*RICHARD~ CLP*CLM-1019*4*430.00*0.00*0.00*MC*ICN-884209*11~ CAS*CO*29*430.00~ NM1*QC*1*POE*ALEX~ SE*13*0001~
{
"claimNumber": "CLM-1011",
"status": "Paid",
"billed": 1250.00,
"paid": 1000.00,
"patientResponsibility": 250.00,
"adjustments": [ { "group": "CO", "reason": "45", "amount": 250.00 } ],
"traceNumber": "EFT-204412",
"remitDate": "2026-06-04"
}The claim status code becomes Paid / Denied / Adjusted, feeding the Status promotion from Step 1 that routes denials to the worklist:
using System.Text.Json; using CC.Art2link.Pipelines.Domain.Models.PipelineComponents; public sealed class X12Config { public string SegmentTerminator { get; set; } = "~"; public string ElementSeparator { get; set; } = "*"; } public sealed class Hipaa835Disassembler : PipelineComponentBase<X12Config> { public override string Name => "Hipaa835Disassembler"; protected override Task<PipelineComponentOutput> ExecuteAsync( PipelineComponentInput input, X12Config config, CancellationToken cancellationToken) { try { var segs = input.Body .Split(config.SegmentTerminator, StringSplitOptions.RemoveEmptyEntries) .Select(s => s.Trim().Split(config.ElementSeparator)) .ToList(); var trace = segs.FirstOrDefault(s => s[0] == "TRN")?[2] ?? ""; var bpr = segs.FirstOrDefault(s => s[0] == "BPR"); var date = bpr is not null ? FormatDate(bpr[^1]) : ""; var messages = new List<PipelineMessage>(); string[]? clp = null; var cas = new List<string[]>(); void Flush() { if (clp is null) return; messages.Add(BuildClaim(clp, cas, trace, date)); cas = new List<string[]>(); } foreach (var s in segs) { if (s[0] == "CLP") { Flush(); clp = s; } else if (s[0] == "CAS" && clp is not null) cas.Add(s); else if (s[0] == "SE") { Flush(); clp = null; } } Flush(); return Task.FromResult(new PipelineComponentOutput { Success = true, Messages = messages }); } catch (Exception ex) { return Task.FromResult(new PipelineComponentOutput { Success = false, ErrorMessage = $"Hipaa835Disassembler: {ex.Message}", Exception = ex }); } } private static string MapStatus(string code) => code switch { "1" => "Paid", "4" => "Denied", _ => "Adjusted" }; private static string FormatDate(string d) => d.Length == 8 ? $"{d[..4]}-{d.Substring(4, 2)}-{d.Substring(6, 2)}" : d; private static PipelineMessage BuildClaim( string[] clp, List<string[]> cas, string trace, string date) { var claim = new { claimNumber = clp[1], status = MapStatus(clp[2]), billed = decimal.Parse(clp[3]), paid = decimal.Parse(clp[4]), patientResponsibility = clp.Length > 5 ? decimal.Parse(clp[5]) : 0m, adjustments = cas.Select(c => new { group = c[1], reason = c[2], amount = decimal.Parse(c[3]) }).ToArray(), traceNumber = trace, remitDate = date }; return new PipelineMessage { Body = JsonSerializer.Serialize(claim), MessageType = "ClaimPayment" }; } }
Create the Scheduler receive port RemitClock, the daily tick that drives the fetch. Everything the ports from here on reference already exists, so each field is a pure selection. (The dropdowns can also create types and maps inline; building the dependencies first keeps each dialog a selection.)
| Setting | Value |
|---|---|
| Adapter | Scheduler |
| Repeat Every | 1 |
| Repeat Unit | Days |
| Daily Window | opens 05:00:00 AM |
Create the two-way SFTP Caller send port RemitFetch against the payer’s (or clearinghouse’s) ERA mailbox, subscribing on {{Config.PortName}} == "RemitClock".
| Setting | Value |
|---|---|
| Adapter | SFTP Caller |
| Way | Two |
| Host | sftp.payer.example |
| Authentication | PayerSftp, from Step 2 |
| Command | Download |
| Remote Directory | /outbound/era |
| Filename | *.835, every new ERA in the mailbox |
In the inbound pipeline, reference Hipaa835Disassembler, from Step 4.
Create the SQL Caller send port PostPayment, subscribing on {{Message.MessageType}} == "ClaimPayment".
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | One |
| Auth Config | BillingDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.PostRemit |
The procedure’s parameters go under Input Parameters, one key/value row each, binding the promotions defined on ClaimPayment in Step 1:
| Parameter | Value |
|---|---|
| ClaimNumber | {{Promoted.ClaimPayment.ClaimNumber}} |
| TraceNumber | {{Promoted.ClaimPayment.TraceNumber}} |
| Billed | {{Promoted.ClaimPayment.Billed}} |
| Paid | {{Promoted.ClaimPayment.Paid}} |
| PatientResp | {{Promoted.ClaimPayment.PatientResp}} |
| Status | {{Promoted.ClaimPayment.Status}} |
| RemitDate | {{Promoted.ClaimPayment.RemitDate}} |
Create the second SQL Caller send port DenialWorklist, subscribing on {{Promoted.ClaimPayment.Status}} == "Denied". A denied claim matches both this port and PostPayment, it is posted and worklisted, which is what the billing team expects.
| Setting | Value |
|---|---|
| Adapter | SQL Caller |
| Way | One |
| Auth Config | BillingDbSql, from Step 2 |
| Command Type | StoredProcedure |
| Command Text | dbo.InsertDenial |
The worklist needs only the key, two rows under Input Parameters:
| Parameter | Value |
|---|---|
| ClaimNumber | {{Promoted.ClaimPayment.ClaimNumber}} |
| TraceNumber | {{Promoted.ClaimPayment.TraceNumber}} |
On all three send ports, RemitFetch, PostPayment and DenialWorklist, set Exception Message Type to RemitPostFailed, from Step 1; the promotion you defined on it carries the claim number into the alert subject. Turn on Activity Notifications (On Error Only) for the parse-failure case.
Add an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "RemitPostFailed", claim numbers and amounts in the body, no patient identifiers:
| Setting | Value |
|---|---|
| Authentication | O365OpsMail, from Step 2 |
| From | noreply@acme.example |
| To | ops@acme.example |
| Subject | 835 remit failure, claim {{Promoted.RemitPostFailed.ClaimNumber}} |
There is nothing to deploy, everything you configured is already saved and live, because Art2link applies changes immediately. To bring the flow online you start the send ports first, then the receive port: start PostPayment, DenialWorklist, OpsAlert and RemitFetch, then start RemitClock. (To take the flow offline, stop the receive port first, for the same reason.)
Before you test, set Tracking to Enabled + Body on every port the flow touches, so you can walk each run step by step, with its message body, in tracking.
Stage the Step 4 sample ERA, one paid, one adjusted, one denied claim, in the payer mailbox’s /outbound/era directory, and trigger the tick. Tracking should show three ClaimPayment publications, three postings, one worklist row.
Re-run the same file and confirm postings converge (no double payment).
Confirm the logs show the full evidence trail, that is the part the auditor will ask for.