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 (CLP01CLM01), 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 CLP07 instead of CLP01. CLP01 is your number returning; CLP07 is the payer’s ICN/DCN. Use CLP01 as the primary key and CLP07 only as a fallback — swapping them cross-links unrelated claims.
  • Overwriting instead of accumulating split payments. A claim can be answered by multiple CLP blocks and reversals. Sum CLP04 by claim (subtracting 22 reversals); 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, not CLP01. When a match fails on a claim you know went to coordination of benefits, consult the REF*F8 before flagging it unmatched.

Up: X12 835 Remittance Structure Breakdown.