Healthcare Payment Posting Automation: ERA Processing, AI Matching, and FHIR Integration
Digital Growth Lead
Writes about healthcare technology, interoperability, and AI-driven transformation across modern care systems.

Part of our complete guide to how AI agents are changing care delivery.
📌 Related guide: this guide covers the posting engine code: parsing, matching tiers, reconciliation and the 835-to-FHIR mapping. For the operating problems billing teams hit (PLBs, takebacks, split EFTs), read payment posting automation: the seven hard parts of the 835.
We build ERA and payment posting automation as part of our RCM software development services.
Payment posting is the financial backbone of healthcare revenue cycle management. Every day, thousands of healthcare organizations receive Electronic Remittance Advice (ERA) files from payers, each containing dozens to thousands of individual payment records that must be accurately matched to outstanding claims, reconciled against expected reimbursement, and posted to patient accounts. Despite its criticality, payment posting remains one of the most labor-intensive and error-prone processes in healthcare revenue cycle management.
Manual posting errors create write-offs, rework and delayed collections. Before automating, measure your own baseline: the posting error rate and the staff hours per 1,000 claim lines. Those two numbers tell you what automation is worth for your practice. With payer reimbursements growing more complex each year (bundled payments, value-based contracts, multiple adjustment reason codes), the case for automation has never been stronger.
This guide goes deep into the technical architecture of payment posting automation: from EDI 835 segment-level parsing through AI-powered exception handling, FHIR integration with ExplanationOfBenefit and ClaimResponse resources, and clearinghouse integration patterns that connect it all together.
Watch: 835 vs EFT: Why an ERA Does Not Mean You Got Paid (8:13).
What Payment Posting Actually Is (and Why It Is Still Manual in 2026)
Payment posting is the process of recording payments received from insurance payers and patients onto the corresponding claims in a practice management or billing system. It sounds simple. In reality, it involves parsing complex electronic data interchange (EDI) files, matching payments to the correct claims using multiple identifiers, interpreting dozens of adjustment reason codes, handling partial payments and denials, and ensuring every dollar is accounted for in the general ledger.
The Manual Payment Posting Workflow
In a typical manual workflow, a billing specialist:
- Receives ERA/EOB files from payers via clearinghouse portals, SFTP, or physical mail (yes, paper EOBs still exist in 2026)
- Opens each remittance and identifies the check/EFT payment and the claims it covers
- Looks up each claim in the billing system using the payer's Internal Control Number (ICN) or patient demographics
- Compares the paid amount against the expected reimbursement from the fee schedule or contract
- Posts the payment to the correct line items, applying adjustment codes for contractual adjustments, patient responsibility, and other modifications
- Identifies exceptions including denials, underpayments, bundled claims, and routing them to the appropriate work queue
- Reconciles the batch by ensuring the total posted matches the total payment received
A single ERA file from a major payer like UnitedHealthcare or Anthem can contain 500+ individual claim payments. A mid-sized billing operation might process 50-100 ERA files daily. That is 5,000 to 50,000 individual payment line items requiring human review, matching, and posting every single day.
Why Automation Has Lagged
Several factors have kept payment posting stubbornly manual:
- EDI complexity: The X12 835 transaction standard is notoriously difficult to parse. Nested segment hierarchies, conditional fields, and payer-specific variations make generic parsers fragile
- Matching ambiguity: Payers do not always return the same claim identifiers submitted. ICN numbers change, patient names have variations, dates of service span ranges. Automated matching must handle fuzzy logic
- Adjustment code interpretation: There are 300+ Claim Adjustment Reason Codes (CARC) and 200+ Remittance Advice Remark Codes (RARC). Each combination requires different downstream actions: write-off, patient billing, appeal, resubmission
- Legacy system limitations: Many practice management systems were designed for manual posting workflows and lack robust APIs for automated input
- Risk aversion: Financial transactions demand accuracy. Organizations fear automated systems making errors that cascade into accounting problems
The ERA/EDI 835 Structure Explained for Developers
The EDI 835 (Health Care Claim Payment/Advice) transaction is the standard electronic format for communicating payment information from payers to providers. Understanding its hierarchical structure is essential for building a robust payment posting automation engine. If you are working with US payer integrations, mastering the 835 is non-negotiable.
Segment Hierarchy
An EDI 835 file follows a strict hierarchical envelope structure:
ISA*00* *00* *ZZ*SENDER_ID *ZZ*RECEIVER_ID *260319*1200*^*00501*000000001*0*P*:~
GS*HP*SENDER_ID*RECEIVER_ID*20260319*1200*1*X*005010X221A1~
ST*835*0001~
BPR*I*15420.50*C*ACH*CCP*01*999999999*DA*123456789*1234567890**01*888888888*DA*987654321*20260319~
TRN*1*TRACE123456*1234567890~
DTM*405*20260319~
N1*PR*AETNA HEALTH INC~
N1*PE*SAMPLE MEDICAL GROUP*XX*1234567890~
CLP*CLM-2026-001*1*1500.00*1200.00**12*ORIG-ICN-001*11~
SVC*HC:99213*150.00*120.00**1~
CAS*CO*45*30.00~
DTM*472*20260215~
SVC*HC:99214*250.00*200.00**1~
CAS*CO*45*50.00~
CAS*PR*3*0.00~
DTM*472*20260215~
CLP*CLM-2026-002*4*500.00*0.00**12*ORIG-ICN-002*11~
SVC*HC:99203*500.00*0.00**1~
CAS*CO*16*500.00~
SE*20*0001~
GE*1*1~
IEA*1*000000001~Critical Segments for Payment Posting
Each segment serves a specific role in the payment posting workflow:
| Segment | Purpose | Key Data Elements | Auto-Posting Usage |
|---|---|---|---|
| BPR | Financial information | Payment method (ACH/CHK), total amount, bank routing, payment date | Batch header: total expected amount for reconciliation |
| TRN | Trace number | Check/EFT reference number, payer ID | Links ERA to actual bank deposit for reconciliation |
| CLP | Claim payment | Patient control number, status (1=processed, 4=denied), billed amount, paid amount, payer claim ID | Primary matching key: ICN/patient control number to original claim |
| SVC | Service line detail | Procedure code (CPT/HCPCS), charge amount, payment amount, units | Line-item posting: each SVC maps to a charge line in the billing system |
| CAS | Claim adjustment | Adjustment group (CO/PR/OA/PI/CR), reason code, amount | Determines write-off, patient responsibility, or appeal action |
| DTM | Date/time reference | Service date (472), coverage period, adjudication date | Secondary matching: validates date of service alignment |
CAS Adjustment Group Codes
The CAS (Claim Adjustment Segment) is where the real complexity lives. Each adjustment has a group code that determines financial responsibility:
- CO (Contractual Obligations): Provider write-off. The difference between billed and allowed amounts per the payer contract. Action: write off, do not bill patient
- PR (Patient Responsibility): Amount owed by the patient (deductible, copay, coinsurance). Action: transfer to patient balance, generate statement
- OA (Other Adjustments): Catch-all for adjustments not fitting CO or PR. Requires manual review of the CARC reason code
- PI (Payer Initiated Reductions): Payer-initiated reductions not tied to contract. Often requires appeal
- CR (Corrections/Reversals, legacy): Reversal of a previous payment. Action: reverse original posting, investigate. CR was a 4010-era group code. The current X12 group code list has only CO, OA, PI and PR, so check whether any of your payers still send CR. In 5010 files, a reversal is signalled by CLP02 = 22 with negative amounts.
Parsing Implementation
A production-grade EDI 835 parser must handle payer-specific variations. Here is a Python implementation that demonstrates the core parsing logic:
import re
from dataclasses import dataclass, field
from typing import List, Optional
from decimal import Decimal
@dataclass
class AdjustmentDetail:
group_code: str # CO, PR, OA, PI, CR
reason_code: str # CARC code (e.g., "45" = charge exceeds fee schedule)
amount: Decimal
quantity: Optional[int] = None
@dataclass
class ServiceLine:
procedure_code: str # CPT or HCPCS code
charge_amount: Decimal
paid_amount: Decimal
units: int = 1
adjustments: List[AdjustmentDetail] = field(default_factory=list)
service_date: Optional[str] = None
@dataclass
class ClaimPayment:
patient_control_number: str
status_code: str # CLP02: 1=processed as primary, 2=processed as secondary,
# 3=processed as tertiary, 4=denied, 19/20/21=processed and forwarded,
# 22=reversal of previous payment, 23=not our claim, forwarded
billed_amount: Decimal
paid_amount: Decimal
payer_claim_id: str
service_lines: List[ServiceLine] = field(default_factory=list)
@dataclass
class ERA835:
trace_number: str
payment_method: str # ACH, CHK
total_payment: Decimal
payer_name: str
payee_name: str
payment_date: str
claims: List[ClaimPayment] = field(default_factory=list)
def parse_835(raw_content: str) -> ERA835:
# Detect segment and element delimiters from ISA header
element_sep = raw_content[3] # Usually '*'
segment_sep = raw_content[105] # Usually '~'
segments = [s.strip() for s in raw_content.split(segment_sep) if s.strip()]
era = ERA835(
trace_number="", payment_method="", total_payment=Decimal("0"),
payer_name="", payee_name="", payment_date=""
)
current_claim = None
current_svc = None
for segment in segments:
elements = segment.split(element_sep)
seg_id = elements[0]
if seg_id == "BPR":
era.payment_method = elements[4] if len(elements) > 4 else "CHK"
era.total_payment = Decimal(elements[2]) if len(elements) > 2 else Decimal("0")
era.payment_date = elements[16] if len(elements) > 16 else ""
elif seg_id == "TRN":
era.trace_number = elements[2] if len(elements) > 2 else ""
elif seg_id == "N1":
qualifier = elements[1] if len(elements) > 1 else ""
name = elements[2] if len(elements) > 2 else ""
if qualifier == "PR":
era.payer_name = name
elif qualifier == "PE":
era.payee_name = name
elif seg_id == "CLP":
current_claim = ClaimPayment(
patient_control_number=elements[1],
status_code=elements[2],
billed_amount=Decimal(elements[3]),
paid_amount=Decimal(elements[4]),
payer_claim_id=elements[7] if len(elements) > 7 else ""
)
era.claims.append(current_claim)
current_svc = None
elif seg_id == "SVC" and current_claim:
proc_code = elements[1].split(":")[1] if ":" in elements[1] else elements[1]
current_svc = ServiceLine(
procedure_code=proc_code,
charge_amount=Decimal(elements[2]),
paid_amount=Decimal(elements[3]),
units=int(elements[5]) if len(elements) > 5 else 1
)
current_claim.service_lines.append(current_svc)
elif seg_id == "CAS":
adj = AdjustmentDetail(
group_code=elements[1],
reason_code=elements[2],
amount=Decimal(elements[3])
)
target = current_svc if current_svc else current_claim
if target and hasattr(target, 'adjustments'):
target.adjustments.append(adj)
elif seg_id == "DTM" and current_svc:
if elements[1] == "472": # Service date
current_svc.service_date = elements[2]
return eraAuto-Posting Rules Engine: Matching Logic, Adjustment Codes, and Denial Routing
The rules engine is the brain of payment posting automation. It takes parsed ERA data and determines whether each payment can be automatically posted, needs verification, or requires manual intervention. A well-designed rules engine achieves 90%+ auto-post rates while maintaining 99%+ accuracy.
Multi-Tier Matching Algorithm
Payment matching operates in cascading tiers, each with decreasing confidence but increasing coverage:
from enum import Enum
from typing import Tuple, Optional
class MatchConfidence(Enum):
EXACT = "exact" # ICN direct match - auto-post
HIGH = "high" # Multi-field match - auto-post with flag
MEDIUM = "medium" # Partial match - review queue
LOW = "low" # Fuzzy match - manual review
DENIAL = "denial" # Denial - denial management queue
NONE = "none" # No match found - exception queue
class PostingAction(Enum):
AUTO_POST = "auto_post"
AUTO_POST_FLAGGED = "auto_post_flagged"
ROUTE_ADJUSTMENT = "route_adjustment"
ROUTE_DENIAL = "route_denial"
ROUTE_MANUAL = "route_manual"
POST_COB = "post_cob" # secondary or tertiary payment: post through COB
REVERSE = "reverse" # CLP02 = 22: reverse the original posting
REVERSAL = "22"
COB_STATUSES = ("2", "3", "20", "21") # processed as secondary or tertiary
def match_payment_to_claim(
claim_payment: ClaimPayment,
billing_system
) -> Tuple[MatchConfidence, PostingAction, Optional[str]]:
# Tier 1: Exact ICN match
matched = billing_system.find_by_icn(claim_payment.payer_claim_id)
if matched:
if claim_payment.status_code == "4": # Denied
return MatchConfidence.DENIAL, PostingAction.ROUTE_DENIAL, matched.id
if claim_payment.status_code == REVERSAL:
return MatchConfidence.EXACT, PostingAction.REVERSE, matched.id
if claim_payment.status_code in COB_STATUSES:
return MatchConfidence.EXACT, PostingAction.POST_COB, matched.id
return MatchConfidence.EXACT, PostingAction.AUTO_POST, matched.id
# Tier 2: Patient control number match
matched = billing_system.find_by_patient_control(
claim_payment.patient_control_number
)
if matched:
tolerance = abs(matched.billed_amount - claim_payment.billed_amount)
if tolerance <= matched.billed_amount * Decimal("0.01"):
if claim_payment.status_code == "4":
return MatchConfidence.HIGH, PostingAction.ROUTE_DENIAL, matched.id
if claim_payment.status_code == REVERSAL:
return MatchConfidence.HIGH, PostingAction.REVERSE, matched.id
if claim_payment.status_code in COB_STATUSES:
return MatchConfidence.HIGH, PostingAction.POST_COB, matched.id
return MatchConfidence.HIGH, PostingAction.AUTO_POST_FLAGGED, matched.id
else:
return MatchConfidence.MEDIUM, PostingAction.ROUTE_ADJUSTMENT, matched.id
# Tier 3: Multi-field fuzzy match
candidates = billing_system.fuzzy_search(
billed_amount_range=(
claim_payment.billed_amount * Decimal("0.95"),
claim_payment.billed_amount * Decimal("1.05")
),
service_date=claim_payment.service_lines[0].service_date if claim_payment.service_lines else None,
procedure_codes=[svc.procedure_code for svc in claim_payment.service_lines]
)
if len(candidates) == 1:
return MatchConfidence.MEDIUM, PostingAction.ROUTE_ADJUSTMENT, candidates[0].id
elif len(candidates) > 1:
return MatchConfidence.LOW, PostingAction.ROUTE_MANUAL, None
return MatchConfidence.NONE, PostingAction.ROUTE_MANUAL, NoneCLP02 code 2 means processed as secondary. Treating it as a reversal would reverse a valid secondary payment. Only code 22 is a reversal of a previous payment, and X12 guidance expects it with negated amounts (see X12 RFI 2442 on reversals and corrections). Payers can also add their own rules, so check each companion guide.
Adjustment Code Processing
Once a payment is matched, each adjustment must be interpreted and routed correctly. The combination of group code + reason code determines the financial action:
# Adjustment routing rules
ADJUSTMENT_RULES = {
# Contractual Obligations - automatic write-off
("CO", "45"): {"action": "write_off", "description": "Charges exceed fee schedule/maximum allowable"},
("CO", "253"): {"action": "write_off", "description": "Sequestration adjustment"},
("CO", "97"): {"action": "write_off", "description": "Payment adjusted - already paid procedure"},
# Patient Responsibility - transfer to patient balance
("PR", "1"): {"action": "patient_balance", "description": "Deductible amount"},
("PR", "2"): {"action": "patient_balance", "description": "Coinsurance amount"},
("PR", "3"): {"action": "patient_balance", "description": "Copay amount"},
# Denials requiring action
("CO", "4"): {"action": "appeal", "description": "Procedure code inconsistent with modifier"},
("CO", "16"): {"action": "resubmit", "description": "Claim lacks information"},
("CO", "18"): {"action": "appeal", "description": "Duplicate claim/service"},
("CO", "29"): {"action": "appeal", "description": "Time limit for filing has expired"},
("CO", "50"): {"action": "appeal", "description": "Non-covered service"},
("CO", "197"): {"action": "appeal", "description": "Precertification/authorization absent"},
# Payer initiated - investigate
("PI", "23"): {"action": "investigate", "description": "Payment adjusted - authorized return"},
("OA", "23"): {"action": "investigate", "description": "Payment adjusted - authorized return"},
# Legacy: CR is not in the current X12 group code list (x12.org).
# Kept for old files; 5010 reversals arrive as CLP02 = 22 with negative amounts.
("CR", "B1"): {"action": "reverse", "description": "Non-covered visits - provider liable"},
}Denial Routing Logic
Denials (CLP status code 4) require specialized routing based on the denial reason. A robust auto-posting system categorizes denials and routes them to the appropriate workflow:
- Authorization denials (CARC 197, 242): Route to prior authorization team for retro-auth request or appeal
- Medical necessity denials (CARC 50, 56): Route to clinical team for peer-to-peer review preparation
- Timely filing denials (CARC 29): Route to compliance with proof-of-timely-filing documentation
- Duplicate claim denials (CARC 18): Auto-check if original payment was received; if yes, mark resolved
- Missing information (CARC 16, 252): Route to coding team with specific missing data elements
- Coordination of benefits (CARC 22): Route to insurance verification to confirm primary/secondary payer order
AI-Powered Exception Handling
Traditional rules engines handle the 85-90% of payments that follow predictable patterns. The remaining 10-15% involves ambiguous matches, partial payments, bundled claims, and unusual adjustment combinations that have historically required human judgment. Machine learning can rank candidate matches for a reviewer. Approval stays with a person until the model's precision on your own history is measured.
- Training data: past posting decisions made by your own staff.
- Measurement: precision at the chosen threshold on a held-out month the model never saw.
- Feedback: log every reviewer override and feed it into the next training run.
Ambiguous Match Resolution
When the rules engine finds multiple potential matching claims, a machine learning model can score candidates based on historical posting patterns:
import numpy as np
from sklearn.ensemble import GradientBoostingClassifier
class AmbiguousMatchResolver:
def __init__(self):
self.model = GradientBoostingClassifier(
n_estimators=200,
max_depth=6,
learning_rate=0.1
)
def extract_features(self, payment, candidate_claim):
return np.array([
# Amount similarity (0-1 scale)
1 - abs(payment.billed_amount - candidate_claim.billed_amount)
/ max(payment.billed_amount, Decimal("1")),
# Date proximity (days difference)
self._date_diff_days(payment, candidate_claim),
# Procedure code overlap ratio
self._procedure_overlap(payment, candidate_claim),
# Provider NPI match (0 or 1)
int(payment.rendering_npi == candidate_claim.rendering_npi),
# Patient name similarity (Jaro-Winkler)
self._name_similarity(payment.patient_name, candidate_claim.patient_name),
# Payer history (how often this payer matches on this pattern)
self._payer_match_history(payment.payer_id, candidate_claim),
# Claim age (days since submission)
self._claim_age_days(candidate_claim),
# Number of competing candidates
len(payment.candidate_claims),
])
def resolve(self, payment, candidates):
if not candidates:
return None, 0.0
scores = []
for candidate in candidates:
features = self.extract_features(payment, candidate)
probability = self.model.predict_proba(features.reshape(1, -1))[0][1]
scores.append((candidate, probability))
scores.sort(key=lambda x: x[1], reverse=True)
best_match, confidence = scores[0]
# Only auto-resolve if confidence > 95% AND margin > 20% over second best
if confidence > 0.95 and (len(scores) < 2 or confidence - scores[1][1] > 0.20):
return best_match, confidence
# Below threshold: return no match, so the caller cannot auto-post.
# The scored candidates go to a person for review.
return None, confidenceA model score routes work; it does not approve a posting. Below-threshold matches go to a person.
Partial Payment Intelligence
Partial payments are among the most complex exceptions. An AI system can learn to distinguish between legitimate partial payments (multi-visit bundling, COB situations, high-deductible plans) and underpayments that require appeal:
- Contract compliance check: Compare paid amount against the expected reimbursement from the loaded payer contract. If the paid amount matches the contracted rate minus patient responsibility, auto-post. If it falls below, flag as underpayment
- Historical pattern analysis: For this specific CPT code + payer combination, what is the typical reimbursement? Statistical outliers (greater than 2 standard deviations below the mean) trigger automatic appeal workflows
- Split payment detection: When a single claim receives multiple partial payments across different ERAs, the system must track cumulative payments and post each installment correctly
- Coordination of Benefits (COB): When a secondary payer remits after the primary, the system must apply the payment to the remaining balance after primary adjustment without double-counting contractual write-offs
Bundled Claim Intelligence
When payers bundle multiple services under a single payment (CARC 97 or National Correct Coding Initiative edits), the AI system must:
- Identify which service lines were bundled (comparing submitted vs. adjudicated line counts)
- Determine if the bundling was clinically appropriate or represents an incorrect edit
- Allocate the payment proportionally across the original charge lines
- Flag potentially incorrect bundles for coding team review and possible appeal
FHIR Integration: ExplanationOfBenefit and ClaimResponse Resources
As healthcare moves toward FHIR-based interoperability, payment posting systems must bridge the gap between legacy EDI 835 transactions and modern FHIR resources. Two FHIR R4 resources are central to this transformation: ExplanationOfBenefit (EOB) and ClaimResponse.
ClaimResponse: The Adjudication Result
The FHIR ClaimResponse resource represents the payer's adjudication decision on a submitted claim. It maps directly to the ERA/835 data and serves as the machine-readable adjudication result:
{
"resourceType": "ClaimResponse",
"status": "active",
"type": {
"coding": [{
"system": "http://terminology.hl7.org/CodeSystem/claim-type",
"code": "professional"
}]
},
"use": "claim",
"patient": {"reference": "Patient/pat-12345"},
"insurer": {"reference": "Organization/aetna-health"},
"created": "2026-03-19",
"outcome": "complete",
"item": [{
"itemSequence": 1,
"adjudication": [
{
"category": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/adjudication", "code": "submitted"}]},
"amount": {"value": 150.00, "currency": "USD"}
},
{
"category": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/adjudication", "code": "benefit"}]},
"amount": {"value": 120.00, "currency": "USD"}
},
{
"category": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/adjudication", "code": "deductible"}]},
"amount": {"value": 0.00, "currency": "USD"}
},
{
"category": {
"coding": [{"system": "https://x12.org/codes/claim-adjustment-group-codes", "code": "CO"}],
"text": "Contractual adjustment"
},
"reason": {"coding": [{"system": "https://x12.org/codes/claim-adjustment-reason-codes", "code": "45"}]},
"amount": {"value": 30.00, "currency": "USD"}
}
]
}],
"payment": {
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/ex-paymenttype", "code": "complete"}]},
"adjustment": {"value": -30.00, "currency": "USD"},
"amount": {"value": 120.00, "currency": "USD"},
"date": "2026-03-19"
},
"total": [
{
"category": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/adjudication", "code": "submitted"}]},
"amount": {"value": 1500.00, "currency": "USD"}
},
{
"category": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/adjudication", "code": "benefit"}]},
"amount": {"value": 1200.00, "currency": "USD"}
}
]
}ExplanationOfBenefit: The Complete Picture
The ExplanationOfBenefit resource combines claim details, adjudication results, and coverage information. It is the FHIR analog to the combination of a submitted claim and its ERA response. Per the HL7 specification, the EOB "SHALL NOT be used as a replacement for a ClaimResponse when responding to Claims" but rather serves as a patient-facing summary of benefits provided.
Key differences between ClaimResponse and EOB for payment posting:
| Aspect | ClaimResponse | ExplanationOfBenefit |
|---|---|---|
| Primary audience | Provider / billing system | Patient / consumer apps |
| Use in posting | Direct input to auto-posting engine | Reference / audit trail |
| Contains claim details | References original Claim | Embeds full claim + response |
| Insurance coverage | Minimal | Full coverage breakdown |
| Care team info | No | Yes (providers, roles) |
| Diagnosis codes | No | Yes (ICD-10 codes) |
EDI 835 to FHIR Transformation
Mapping EDI 835 segments to FHIR resources follows a structured transformation pipeline:
ADJ_SYSTEM = "http://terminology.hl7.org/CodeSystem/adjudication"
X12_GROUP = "https://x12.org/codes/claim-adjustment-group-codes"
# FHIR R4 adjudication codes chosen from group + CARC.
# R4 has no coinsurance code, so PR-2 keeps the X12 group code and a text label.
CARC_CATEGORY = {("PR", "1"): "deductible", ("PR", "3"): "copay"}
CARC_LABEL = {("PR", "2"): "Coinsurance", ("CO", "45"): "Contractual adjustment"}
def adjudication_category(group_code, reason_code):
code = CARC_CATEGORY.get((group_code, reason_code))
if code:
# The group code stays visible in the text, so PR is never lost.
return {"coding": [{"system": ADJ_SYSTEM, "code": code}], "text": f"{group_code}-{reason_code}"}
label = CARC_LABEL.get((group_code, reason_code), f"Adjustment {group_code}-{reason_code}")
return {"coding": [{"system": X12_GROUP, "code": group_code}], "text": label}
def transform_835_to_fhir(era: ERA835) -> list:
fhir_responses = []
for claim in era.claims:
claim_response = {
"resourceType": "ClaimResponse",
"status": "active",
"type": {"coding": [{"system": "http://terminology.hl7.org/CodeSystem/claim-type", "code": "professional"}]},
"use": "claim",
"outcome": "complete" if claim.status_code == "1" else "error",
"disposition": f"Payer ICN: {claim.payer_claim_id}",
"item": [],
"payment": {
"type": {"coding": [{"code": "complete" if claim.status_code == "1" else "partial"}]},
"amount": {"value": float(claim.paid_amount), "currency": "USD"},
"date": era.payment_date
}
}
for idx, svc in enumerate(claim.service_lines):
item = {
"itemSequence": idx + 1,
"adjudication": [
{
"category": {"coding": [{"code": "submitted"}]},
"amount": {"value": float(svc.charge_amount), "currency": "USD"}
},
{
"category": {"coding": [{"code": "benefit"}]},
"amount": {"value": float(svc.paid_amount), "currency": "USD"}
}
]
}
# Map CAS adjustments to FHIR adjudication entries.
# The group code (CO, PR, OA, PI) says who is responsible; the CARC says why.
# Category comes from group + CARC, never from the group alone.
for adj in svc.adjustments:
item["adjudication"].append({
"category": adjudication_category(adj.group_code, adj.reason_code),
"reason": {"coding": [{"system": "https://x12.org/codes/claim-adjustment-reason-codes", "code": adj.reason_code}]},
"amount": {"value": float(adj.amount), "currency": "USD"}
})
claim_response["item"].append(item)
fhir_responses.append(claim_response)
return fhir_responsesThe group code says who is responsible; the reason code says why. Map both, and do not infer deductible from CO. In the code above, PR-1 maps to deductible and PR-3 to copay. FHIR R4 has no coinsurance code in its adjudication value set, so PR-2 keeps the X12 group code and a text label.
Clearinghouse Integration Patterns
Clearinghouses serve as the intermediaries between healthcare providers and payers, handling the transmission, translation, and delivery of ERA/835 files. The three major clearinghouses (Availity, Change Healthcare, and Waystar) each offer different integration patterns for automating ERA retrieval and payment posting.
You can also grab our healthcare integration cost estimator to plan this work.
Availity
The capabilities in this section are as each vendor described them when this guide was written. Before you design around them, confirm with the vendor: ERA delivery method, API authentication and FHIR support (checked 15 October 2026: not yet confirmed against vendor developer documentation).
Availity is a large multi-payer clearinghouse and serves as the primary clearinghouse for many large health plans. Key integration capabilities:
- ERA delivery: SFTP polling (batch, every 15-60 minutes) and REST API (real-time, webhook notifications)
- API format: RESTful JSON with OAuth 2.0 authentication. Supports X12 835 retrieval in both raw EDI and pre-parsed JSON formats
- Batch processing: Up to 10,000 claims per batch via SFTP. API supports individual claim-level polling
- Real-time status: Webhook notifications when new ERAs are available. Eliminates polling latency
- FHIR support: R4 ExplanationOfBenefit endpoint available through Availity Essentials platform
Change Healthcare (Optum)
Now part of Optum/UnitedHealth Group, Change Healthcare processes claims for a large share of US physicians and hospitals:
- ERA delivery: SFTP with Connect platform for real-time streaming. Legacy SOAP and modern REST APIs available
- API format: Dual support for SOAP (legacy integrations) and REST JSON (new implementations). The Intelligent Healthcare Network provides pre-parsed ERA data
- Batch processing: Highest capacity at 50,000+ claims per batch. Optimized for large health systems
- Advanced analytics: Built-in denial prediction and underpayment detection through AI/ML analytics layer
- FHIR support: Partial R4 support. Full ClaimResponse mapping available through premium tier
Waystar
Waystar focuses on revenue cycle automation with deep EHR integrations:
- ERA delivery: SFTP + portal-based retrieval with REST API for automated workflows
- API format: RESTful JSON with API-key authentication. Pre-built connectors for 30+ practice management systems
- Auto-posting engine: Waystar includes a built-in auto-posting module that handles the matching logic internally, reducing integration complexity
- Denial management: Integrated denial analytics with root cause analysis and automated appeal generation
- FHIR support: Full R4 support including ExplanationOfBenefit resources with rich adjudication data
Integration Architecture
A production payment posting system typically integrates with clearinghouses using a layered architecture:
# Clearinghouse integration abstraction layer
class ClearinghouseAdapter:
def fetch_new_eras(self, since_datetime=None) -> list:
raise NotImplementedError
def acknowledge_era(self, era_id: str) -> bool:
raise NotImplementedError
def get_era_status(self, trace_number: str) -> dict:
raise NotImplementedError
class AvailityAdapter(ClearinghouseAdapter):
def __init__(self, client_id, client_secret, base_url="https://api.availity.com"):
self.base_url = base_url
self.token = self._authenticate(client_id, client_secret)
def fetch_new_eras(self, since_datetime=None):
params = {"status": "new", "transactionType": "835"}
if since_datetime:
params["fromDate"] = since_datetime.strftime("%Y-%m-%d")
response = self._get("/v1/remittance-advices", params=params)
return [self._parse_era(era) for era in response.get("remittances", [])]
def acknowledge_era(self, era_id):
return self._post(f"/v1/remittance-advices/{era_id}/acknowledge")
class PaymentPostingOrchestrator:
def __init__(self, clearinghouse, billing_system, rules_engine):
self.clearinghouse = clearinghouse
self.billing_system = billing_system
self.rules_engine = rules_engine
def process_batch(self):
eras = self.clearinghouse.fetch_new_eras()
results = {"auto_posted": 0, "flagged": 0, "cob": 0, "reversed": 0, "denied": 0, "manual": 0}
for era_835 in eras:
parsed = parse_835(era_835.raw_content)
for claim in parsed.claims:
confidence, action, claim_id = self.rules_engine.match(claim)
if action == PostingAction.AUTO_POST:
self.billing_system.post_payment(claim_id, claim)
results["auto_posted"] += 1
elif action == PostingAction.AUTO_POST_FLAGGED:
self.billing_system.post_payment(claim_id, claim, flagged=True)
results["flagged"] += 1
elif action == PostingAction.POST_COB:
self.billing_system.post_payment(claim_id, claim, cob=True)
results["cob"] += 1
elif action == PostingAction.REVERSE:
self.billing_system.reverse_payment(claim_id, claim)
results["reversed"] += 1
elif action == PostingAction.ROUTE_DENIAL:
self.billing_system.queue_denial(claim_id, claim)
results["denied"] += 1
else:
self.billing_system.queue_manual_review(claim)
results["manual"] += 1
self.clearinghouse.acknowledge_era(era_835.id)
return resultsReconciliation: Matching Posted Amounts to Expected Reimbursement
Reconciliation is the final verification step that ensures financial integrity. Every payment batch must balance: the total amount deposited in the bank (per the BPR segment) must equal the sum of all individual claim payments posted, plus or minus any provider-level adjustments.
Three-Way Reconciliation
Production payment posting systems perform three-way reconciliation:
- ERA-to-Bank: The total in the BPR segment matches the actual bank deposit (ACH or check). Discrepancies indicate missing ERAs, split payments, or bank processing errors
- ERA-to-Posting: The sum of all individual CLP paid amounts matches the total posted to patient accounts. Discrepancies indicate posting errors, missed claims, or double-postings
- Posting-to-Contract: Each individual payment matches the expected reimbursement per the payer contract. Systematic underpayments trigger contract compliance reviews and potential appeal escalation
Variance Detection and Alerting
Automated variance detection should flag:
- Batch imbalance: When total posted differs from ERA total by more than $0.01. This threshold catches rounding issues while allowing for legitimate penny-level adjustments
- Underpayment patterns: When a specific payer consistently pays below contracted rates for a CPT code. Machine learning models can identify emerging underpayment trends before they become systemic revenue leaks
- Takeback alerts: When a reversal (CLP status 22) is received for a previously posted payment. Immediate notification prevents the reversed amount from aging in accounts receivable
- Duplicate payment detection: When the same claim receives payment from the same payer more than once. These must be identified and returned to avoid overpayment recovery demands
Provider-level adjustments (PLB) and balancing the 835
Every 835 has to balance before you post it. The rule is: the payment in BPR02 equals the sum of the claim payments in CLP04, minus the sum of the provider-level adjustment amounts in the PLB segment. A positive PLB amount reduces the payment, and a negative one increases it. PLB lines are not tied to one claim on this remittance, so a claim-by-claim posting engine misses them unless it reads PLB on purpose.
The PLB reason codes billing teams see most often are:
- WO (overpayment recovery): the payer is taking back money paid on an earlier claim. Post it against the original claim named in the reference, not against the claims on this file.
- FB (forwarding balance): a balance carried from or to another remittance, usually when a recovery is larger than this payment. Track it per payer until it nets to zero.
- L6 (interest owed): interest the payer adds to the payment, usually as a negative PLB amount. Post it to an interest income account, not to a patient claim.
Here is a short synthetic 835 (envelope segments trimmed) where a WO takeback of $50.00 reduces two claim payments of $120.00 and $80.00 to a deposit of $150.00:
BPR*I*150.00*C*ACH*CCP*01*999999999*DA*123456789*1234567890**01*888888888*DA*987654321*20260319~
TRN*1*TRACE778899*1234567890~
CLP*CLM-2026-101*1*150.00*120.00**12*ICN-101*11~
CLP*CLM-2026-102*1*100.00*80.00**12*ICN-102*11~
PLB*1234567890*20261231*WO:ICN-077*50.00~
SE*7*0001~The posting it produces: $120.00 and $80.00 posted to the two claims, a $50.00 recovery entry against the earlier claim ICN-077, and a deposit of $150.00 that matches the bank. The balancing check is small enough to run on every file:
from decimal import Decimal
def check_835_balance(bpr02, clp04_amounts, plb_amounts):
"""BPR02 must equal sum(CLP04) minus sum(PLB amounts).
A positive PLB amount (for example WO) reduces the payment;
a negative one (for example L6 interest) increases it."""
expected = sum(clp04_amounts, Decimal("0")) - sum(plb_amounts, Decimal("0"))
return Decimal(bpr02) == expected
# The file above: 120.00 + 80.00 - 50.00 = 150.00
assert check_835_balance(Decimal("150.00"), [Decimal("120.00"), Decimal("80.00")], [Decimal("50.00")])
# Ignoring the PLB takeback breaks the balance: 200.00 != 150.00
assert not check_835_balance(Decimal("150.00"), [Decimal("120.00"), Decimal("80.00")], [])Payers differ in how they use PLB, so check each payer's companion guide. UnitedHealthcare publishes one for EDI 835 provider-level adjustments. For the operating side of takebacks and split payments, read the seven hard parts of the 835.
Best Payment Posting Software Compared
The payment posting automation market includes purpose-built solutions, RCM platform modules, and clearinghouse-native tools. Here is how the leading options compare for mid-sized to large healthcare organizations:
| Solution | What to ask the vendor | Integration | AI/ML | FHIR Support | Best For |
|---|---|---|---|---|---|
| Waystar | Auto-post rate on your payer mix, measured how? | 30+ PM/EHR connectors | Yes (denial prediction) | R4 | Multi-specialty groups with diverse EHR landscape |
| Availity Essentials+ | Auto-post rate on your payer mix, measured how? | REST API + SFTP | Limited | R4 | Organizations already on Availity platform |
| Change Healthcare RCM | Auto-post rate on your payer mix, measured how? | SOAP + REST | Yes (Intelligent Network) | Partial | Large health systems with high volume |
| Trizetto (Cognizant) | Auto-post rate on your payer mix, measured how? | Facets integration | Limited | No | Payer-side payment processing |
| Inovalon | Auto-post rate on your payer mix, measured how? | REST API | Yes | R4 | Value-based care organizations |
| Custom build | Auto-post rate on your payer mix, measured how? | Full control | Customizable | Full | Organizations with unique workflows or legacy systems |
Vendors publish no common auto-post benchmark. Ask for a pilot on your own 835 files.
The choice between buy and build depends on several factors: transaction volume, existing system landscape, payer mix complexity, and internal engineering capability. Organizations processing fewer than 50,000 claims monthly typically benefit from off-the-shelf solutions. Those with higher volumes, complex multi-payer contracts, or unique business rules often find that a custom-built solution pays for itself within 12-18 months through higher auto-post rates and reduced exception handling costs.
Implementation Roadmap
Implementing payment posting automation is a phased journey, not a big-bang deployment. Here is a proven 16-week roadmap:
Phase 1: Foundation (Weeks 1-4)
- Week 1-2: EDI 835 parser development and testing against real ERA files from your top 5 payers. Validate parsing accuracy against manual posting records
- Week 3: Billing system integration. Build the API or database connector to your practice management system. Map internal claim IDs to payer ICN numbers
- Week 4: Clearinghouse connectivity. Establish SFTP connections and API credentials with your primary clearinghouse
Phase 2: Rules Engine (Weeks 5-8)
- Week 5-6: Implement Tier 1 (exact ICN match) and Tier 2 (patient control number match) auto-posting. This alone typically achieves 70-80% auto-post rates
- Week 7: Adjustment code routing rules for the top 50 CARC codes that represent 95% of your adjustment volume
- Week 8: Reconciliation engine with three-way balancing and variance alerting
Phase 3: Intelligence Layer (Weeks 9-12)
- Week 9-10: ML-based ambiguous match resolution. Train on 6+ months of historical posting decisions. Target: resolve 60-70% of Tier 3 exceptions automatically
- Week 11: Partial payment intelligence and underpayment detection using contract fee schedules
- Week 12: Denial categorization and automated appeal routing
Phase 4: Optimization (Weeks 13-16)
- Week 13-14: FHIR resource generation for downstream interoperability. Transform ERA data into ClaimResponse and EOB resources
- Week 15: Dashboard and reporting: auto-post rates, exception trends, payer performance, reconciliation status
- Week 16: Production hardening, monitoring, and alert configuration. Establish SLAs for exception resolution time
Expected Outcomes by Phase
Illustrative targets, not measured results. Replace them with your own baseline after Phase 1.
| Metric | Phase 1 | Phase 2 | Phase 3 | Phase 4 | How to measure |
|---|---|---|---|---|---|
| Auto-post rate | 0% (parsing only) | 75-85% | 90-95% | 93-97% | Auto-posted claim lines divided by total claim lines, per week |
| Error rate | N/A | <1% | <0.5% | <0.2% | Postings corrected later divided by total postings, per month |
| Exception resolution time | Manual | 2-4 hours | 30-60 min | 15-30 min | Median time from exception created to exception closed |
| Staff hours per 1K claims | 40 hrs | 12 hrs | 5 hrs | 3 hrs | Posting staff hours divided by claim lines posted, times 1,000 |
Ready to scale?
Talk to our healthcare engineering team about building, integrating, and shipping faster.
Frequently Asked Questions
What is the difference between ERA and EOB in payment posting?
What auto-post rate should our organization target?
How do you handle payer-specific EDI 835 variations?
Can payment posting automation integrate with our existing EHR/PM system?
What are the most common CARC codes that cause payment posting exceptions?
How does Nirmitee.io approach healthcare software development?

