Skip to content
Art2link ESB v2.02 LTS HomeDocumentationBlogContact
Tutorials/Healthcare & EDI/HIPAA 835 remittance posting

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.

One remittance file becomes one record per claim Insurer sends remittance file many claims, one file Split into one per claim Billing system every claim posted here Follow-up worklist denied claims, for a person to chase all claims denied only

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:

EXPRESSIONClaimPayment subscription
{{Message.MessageType}} == "ClaimPayment"
EXPRESSIONDenial subscription
{{Promoted.ClaimPayment.Status}} == "Denied"
835 ERA (SFTP fetch) CLP*CLM-1011… CLP*CLM-1012… CLP*CLM-1019… 835 disassembler one msg per CLP loop BUS all Status == "Denied" SQL Caller post to billing DB SQL Caller denial worklist Billing DB

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

EXPRESSIONOpsAlert subscription
{{Message.MessageType}} == "RemitPostFailed"
Post idempotently on claim + check number. Payers re-publish ERAs after corrections, and a replayed run must not double-post a payment. Key the posting procedure on claim number + payer check/EFT trace number (TRN), and a re-fetched file converges instead of paying twice.

Build it, step by step. The steps run in dependency order: every object is created before the object that selects it.

Before you start. Every artifact below, ports, message types, the disassembler, lives inside one Application. Under Applications, create it first, Name and Namespace GenHosp, both code-safe, no dots or special characters, and select it so the steps build into it.
1
Step One
Create the message types
The two types

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:

NameFormatPurpose
ClaimPaymentJSONone claim payment per CLP loop, as the disassembler (Step 4) publishes it
RemitPostFailedJSONthe 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.

Promotions on ClaimPayment
PromotionPath
Status$.status
ClaimNumber$.claimNumber
TraceNumber$.traceNumber
Billed$.billed
Paid$.paid
PatientResp$.patientResponsibility
RemitDate$.remitDate
Promotion on RemitPostFailed

A failed posting re-publishes payload intact, but promotions are type-qualified, so the exception type needs its own:

PromotionPath
ClaimNumber$.claimNumber

2
Step Two
Create the Authentications

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.

Payer mailbox, for the SFTP Caller
SettingValue
NamePayerSftp
AdapterSFTP Caller
DefinitionSFTP Password Authentication
Username / Passwordthe ERA mailbox credentials
Host Key Fingerprintthe payer’s pinned host key
Billing database, for the SQL Caller
SettingValue
NameBillingDbSql
AdapterSQL Caller
DefinitionSQL Server Connection
Connection String (Database Config)the billing database’s connection string, credentials included
Alert mailbox, for O365 Mail
SettingValue
NameO365OpsMail
AdapterO365 Mail Sender
DefinitionMicrosoft Graph
Tenant Id / Client Id / Client Secret (Graph AuthConfig)an app registration with Mail.Send granted

3
Step Three
Create the database objects

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.

The posting and worklist objects
SQLBilling schema and procedures
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;

4
Step Four
Build the 835 disassembler
Hipaa835Disassembler

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.

The sample 835
EDISample 835 ERA
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~
One ClaimPayment per CLP loop
JSONClaimPayment output
{
  "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 component code

The claim status code becomes Paid / Denied / Adjusted, feeding the Status promotion from Step 1 that routes denials to the worklist:

C#Hipaa835Disassembler.cs
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"
        };
    }
}

5
Step Five
Create the Scheduler clock
General

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

SettingValue
AdapterScheduler
Repeat Every1
Repeat UnitDays
Daily Windowopens 05:00:00 AM

6
Step Six
Create the fetch port
General

Create the two-way SFTP Caller send port RemitFetch against the payer’s (or clearinghouse’s) ERA mailbox, subscribing on {{Config.PortName}} == "RemitClock".

SettingValue
AdapterSFTP Caller
WayTwo
Hostsftp.payer.example
AuthenticationPayerSftp, from Step 2
CommandDownload
Remote Directory/outbound/era
Filename*.835, every new ERA in the mailbox
Reference the disassembler

In the inbound pipeline, reference Hipaa835Disassembler, from Step 4.


7
Step Seven
Create the posting port
General

Create the SQL Caller send port PostPayment, subscribing on {{Message.MessageType}} == "ClaimPayment".

SettingValue
AdapterSQL Caller
WayOne
Auth ConfigBillingDbSql, from Step 2
Command TypeStoredProcedure
Command Textdbo.PostRemit
Input Parameters

The procedure’s parameters go under Input Parameters, one key/value row each, binding the promotions defined on ClaimPayment in Step 1:

ParameterValue
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}}

8
Step Eight
Create the worklist port
General

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.

SettingValue
AdapterSQL Caller
WayOne
Auth ConfigBillingDbSql, from Step 2
Command TypeStoredProcedure
Command Textdbo.InsertDenial
Input Parameters

The worklist needs only the key, two rows under Input Parameters:

ParameterValue
ClaimNumber{{Promoted.ClaimPayment.ClaimNumber}}
TraceNumber{{Promoted.ClaimPayment.TraceNumber}}

9
Step Nine
Wire the failure path
Set the Exception Message Type

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.

Create the OpsAlert send port

Add an O365 Mail send port OpsAlert subscribing on {{Message.MessageType}} == "RemitPostFailed", claim numbers and amounts in the body, no patient identifiers:

SettingValue
AuthenticationO365OpsMail, from Step 2
Fromnoreply@acme.example
Toops@acme.example
Subject835 remit failure, claim {{Promoted.RemitPostFailed.ClaimNumber}}

10
Step Ten
Start the ports and test
Start in dependency order

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

Turn on tracking

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.

Trigger the tick

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.

Prove the idempotency

Re-run the same file and confirm postings converge (no double payment).

Check the evidence trail

Confirm the logs show the full evidence trail, that is the part the auditor will ask for.

Turn bodies off in production. Enabled + Body is the most expensive tracking level, extra processing and database space, and it records every payload including successful runs. Once the flow is proven, set the ports to Only on Error instead: failures still capture the full body for diagnosis, while healthy runs are not recorded.