Matching X12 835 CLP Segments Back to Submitted 837 Claims
Problem: an X12 835 remittance arrives paying a batch of claims, but the CLP01 patient control number on a payment does not line up with the CLM01 your 837 submitter wrote — leading zeros were stripped, a suffix was appended, or the payer paid on its own claim number in CLP07 — so payments strand as unmatched cash and a partially paid claim looks unpaid. This guide gives the Python to build a claim index from submitted 837 claims and join each 835 CLP back to it deterministically: normalizing control numbers, reassociating the ERA to the EFT via TRN, handling split and partial payments, falling back to the payer-assigned CLP07, and flagging what still will not match. It sits under the X12 835 Remittance Structure Breakdown and is written for revenue-cycle engineers and medical billing developers.
Prerequisites
Spec Reference: The Reassociation Keys
Matching is a join over identifiers the payer echoes back. CLP01 is the primary key; CLP07 is the payer’s own number, used as a fallback and to disambiguate; TRN02 reassociates the whole remittance to the deposit it settles.
| Element | Name | Role in matching | Notes |
|---|---|---|---|
CLM01 |
Patient control number (837) | Left side of the join | What your submitter assigned; must be echoed in CLP01 |
CLP01 |
Claim submitter identifier (835) | Primary match key | The payer’s echo of CLM01; normalize before comparing |
CLP02 |
Claim status | Partial/split signal | 1 paid, 2 denied, 3 partial, 4 denied-by-review, 22 reversal |
CLP04 |
Total paid | Split accumulation | Multiple CLP for one claim must sum across remittances |
CLP07 |
Payer claim control number | Secondary/fallback key | The payer’s internal number (ICN/DCN); stable across resubmissions |
TRN02 |
Reassociation trace | ERA↔EFT match | Payer EFT/check trace; ties the file to a deposit |
TRN03 |
Originating company id | Payer disambiguation | Distinguishes payers when CLP01 values collide |
REF*F8 |
Original reference | Crossover/COB link | Carries the prior payer’s claim number on secondary remittances |
Two distinctions cause most stranded payments. First, CLP01 is not CLP07: CLP01 is your number coming home, CLP07 is the payer’s number, and matching on the wrong one either misses or cross-links claims. Second, a single 837 claim can be answered by more than one CLP — across two remittances (a split payment, common when a payer adjudicates lines on different cycles) or with a 22 reversal followed by a corrected payment — so the match must accumulate CLP04, not overwrite it.
Step-by-Step Implementation
Step 1 — Normalize control numbers on both sides
The single highest-yield fix is a canonical form applied identically to CLM01 and CLP01. Payers upshift case, strip or pad leading zeros, and drop punctuation; comparing raw strings misses matches that are semantically identical. Normalize once, store the normalized key, and never compare raw values.
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from decimal import Decimal
logging.basicConfig(format="%(asctime)s | %(levelname)s | %(message)s", level=logging.INFO)
logger = logging.getLogger("x12_835.match")
_NON_ALNUM = re.compile(r"[^A-Za-z0-9]")
def normalize_ccn(raw: str) -> str:
"""Canonical control-number form for CLM01 / CLP01 comparison.
Upshift, strip punctuation, and drop leading zeros so that
'clm-000123', 'CLM000123', and 'CLM123' all resolve identically.
"""
stripped = _NON_ALNUM.sub("", raw).upper()
# Split alpha prefix from numeric tail; zero-normalize only the tail.
m = re.match(r"^([A-Z]*)(\d.*)$", stripped)
if not m:
return stripped
prefix, tail = m.groups()
return f"{prefix}{tail.lstrip('0') or '0'}"
Step 2 — Build the claim index from submitted 837 claims
Index every open claim by its normalized CLM01 and, separately, by any payer claim id already known from a prior acknowledgment. The secondary index is what lets a CLP07-only remittance still match. Guard against collisions: if two claims normalize to the same key, keep both and resolve later with TRN03.
@dataclass
class SubmittedClaim:
clm01: str # as submitted (837 2300 CLM01)
payer_id: str # normalized payer identifier
charge: Decimal
payer_claim_id: str | None = None # ICN/DCN if known from a prior 277CA/835
@dataclass
class ClaimIndex:
by_ccn: dict[str, list[SubmittedClaim]] = field(default_factory=dict)
by_payer_id: dict[str, SubmittedClaim] = field(default_factory=dict)
def add(self, claim: SubmittedClaim) -> None:
key = normalize_ccn(claim.clm01)
self.by_ccn.setdefault(key, []).append(claim)
if claim.payer_claim_id:
self.by_payer_id[normalize_ccn(claim.payer_claim_id)] = claim
def build_index(claims: list[SubmittedClaim]) -> ClaimIndex:
idx = ClaimIndex()
for c in claims:
idx.add(c)
logger.info("claim index built ccn_keys=%d payer_keys=%d",
len(idx.by_ccn), len(idx.by_payer_id))
return idx
Step 3 — Join each CLP with a tiered fallback
Try the strong key first (CLP01 → CLM01), disambiguate collisions by payer, then fall back to CLP07 → payer claim id. Anything that survives all tiers is genuinely unmatched and must be flagged, never force-fit.
@dataclass
class ClpPayment:
clp01: str # payer echo of CLM01
clp02_status: str # 1/2/3/4/22
clp04_paid: Decimal
clp07: str | None # payer claim control number
payer_id: str
@dataclass
class MatchResult:
clp: ClpPayment
claim: SubmittedClaim | None
method: str # CLP01 | CLP07 | UNMATCHED
def match_clp(clp: ClpPayment, idx: ClaimIndex) -> MatchResult:
# Tier 1: primary key CLP01 -> CLM01
candidates = idx.by_ccn.get(normalize_ccn(clp.clp01), [])
if len(candidates) == 1:
return MatchResult(clp, candidates[0], "CLP01")
if len(candidates) > 1: # collision: disambiguate by payer id
for c in candidates:
if c.payer_id == clp.payer_id:
return MatchResult(clp, c, "CLP01")
# Tier 2: fallback to payer-assigned CLP07 -> known ICN/DCN
if clp.clp07:
by_payer = idx.by_payer_id.get(normalize_ccn(clp.clp07))
if by_payer is not None:
return MatchResult(clp, by_payer, "CLP07")
logger.warning("unmatched CLP clp01=%s clp07=%s payer=%s",
clp.clp01, clp.clp07, clp.payer_id)
return MatchResult(clp, None, "UNMATCHED")
Step 4 — Accumulate split and partial payments, flag the rest
A claim answered by several CLP blocks must accumulate paid amounts and track a 22 reversal as a negative. Group matched results by claim and sum; route every UNMATCHED result to its own queue with the reassociation trace so a human can chase the deposit.
def reconcile_remittance(
trn02: str, payments: list[ClpPayment], idx: ClaimIndex
) -> tuple[dict[str, Decimal], list[MatchResult]]:
"""Return {clm01: total_paid} for matched claims and the unmatched list."""
paid_by_claim: dict[str, Decimal] = {}
unmatched: list[MatchResult] = []
for clp in payments:
result = match_clp(clp, idx)
if result.claim is None:
unmatched.append(result)
continue
# A '22' reversal carries a negative CLP04 and backs the payment out.
amount = -clp.clp04_paid if clp.clp02_status == "22" else clp.clp04_paid
key = normalize_ccn(result.claim.clm01)
paid_by_claim[key] = paid_by_claim.get(key, Decimal("0.00")) + amount
logger.info("remittance reconciled trn=%s matched_claims=%d unmatched=%d",
trn02, len(paid_by_claim), len(unmatched))
return paid_by_claim, unmatched
Verification
Feed a remittance where one claim pays in full, one arrives as a split (two CLP for the same claim), and one references a CLP07 the primary key cannot resolve. Assert the split sums and the unmatched count.
idx = build_index([
SubmittedClaim("CLM-000123", payer_id="60054", charge=Decimal("200.00")),
SubmittedClaim("CLM-000900", payer_id="60054", charge=Decimal("400.00"),
payer_claim_id="ICN778"),
])
payments = [
ClpPayment("CLM000123", "1", Decimal("120.00"), None, "60054"), # normalizes + matches
ClpPayment("CLM-000900", "3", Decimal("150.00"), "ICN778", "60054"), # split part 1
ClpPayment("CLM-000900", "3", Decimal("100.00"), "ICN778", "60054"), # split part 2
ClpPayment("UNKNOWN-1", "1", Decimal("75.00"), "ICN778", "60054"), # CLP07 fallback
]
paid, unmatched = reconcile_remittance("EFT884412", payments, idx)
assert paid[normalize_ccn("CLM-000123")] == Decimal("120.00")
assert paid[normalize_ccn("CLM-000900")] == Decimal("325.00") # 150 + 100 + 75 via CLP07
assert unmatched == []
Expected log output (control numbers only, no PHI):
2026-07-16 10:22:03 | INFO | claim index built ccn_keys=2 payer_keys=1
2026-07-16 10:22:03 | INFO | remittance reconciled trn=EFT884412 matched_claims=2 unmatched=0
A stubborn mismatch — a CLP01 that normalizes to no key and carries no resolvable CLP07 — should surface as exactly one UNMATCHED result carrying TRN02, so the payment can be traced to its deposit rather than lost.
Common Gotchas
- Comparing raw control numbers. Payers restyle
CLM01— case, leading zeros, punctuation. Normalize both sides with the same function before joining, or semantically identical claims miss and strand as unmatched cash. - Matching on
CLP07instead ofCLP01.CLP01is your number returning;CLP07is the payer’s ICN/DCN. UseCLP01as the primary key andCLP07only as a fallback — swapping them cross-links unrelated claims. - Overwriting instead of accumulating split payments. A claim can be answered by multiple
CLPblocks and reversals. SumCLP04by claim (subtracting22reversals); a last-write-wins update silently discards a partial payment. - Ignoring secondary-payer COB. On a crossover remittance the primary payer’s claim number often rides in a
REF*F8, notCLP01. When a match fails on a claim you know went to coordination of benefits, consult theREF*F8before flagging it unmatched.
Related
- Parent guide: X12 835 Remittance Structure Breakdown — the segment hierarchy and Pydantic models that expose
CLP01,CLP07, andTRN02. - Posting 835 Payments to Internal AR Ledgers — the balancing and idempotent posting that runs once a
CLPis matched to its claim. - X12 837P Segment Architecture Guide — where
CLM01, the left side of this join, is assembled in the 2300 loop.