Skip to content

How to reconcile camt.053 statements: format, batch entries, reversals, and MT940 mapping

To reconcile a camt.053 bank statement, sign each entry amount from its credit or debit indicator, split batch entries into their transaction details, and match entries on the bank reference and the end-to-end identification. Then check that the opening booked balance plus all entries equals the closing booked balance. Any gap means a parsing defect or a missing entry.

How camt.053 parsing puts wrong numbers in the ledger

A supplier payment run of three invoices leaves the account as one debit of EUR 2,500.00. The bank reports it in camt.053 as a single Ntry with a Btch block and three TxDtls. Later the same day one payment of EUR 850.50 comes back and the bank books it as a credit with RvslInd set to true. A parser that reads only the entry level cannot close any of the three invoices, because the entry carries the payment run reference and no invoice reference. A parser that treats RvslInd as "negate the amount" posts the return as a second debit, so the ledger shows EUR 1,701.00 less cash than the bank holds, and the supplier whose payment was returned stays marked as paid until someone checks the account by hand.

These defects pass tests built on one payment per entry and no returns. They surface on accounts where the bank books payment runs as batches, and during a migration from MT940, when two formats describe the same bookings with different references. For platforms that pay out on behalf of customers, such as AP and AR automation and accounting platforms that import bank feeds, a mismatch between ledger and statement is also a gap in the reconciliation evidence a partner bank reviews; the ISO 20022 testing requirements page lists that evidence. Break classes and date windows of the matcher are in the ledger reconciliation testing guide; this guide covers the statement file that feeds it.

Procedure

1. Pin the message version and the usage guideline

Element names below follow camt.053.001.08, BankToCustomerStatementV08, as restricted by the Payments Canada Lynx corporate-to-bank usage guideline (19 April 2024) and the CBPR+ usage guideline published by Clearstream (SR2025, 26 November 2025), both checked on 2026-09-22. The CBPR+ guideline notes that the CGI-MP camt.053 guidelines are built on version 02, so a platform can receive either version. In version 02 the entry status is a code, <Sts>BOOK</Sts>; in version 08 it is a choice, <Sts><Cd>BOOK</Cd></Sts>. Read the namespace of each file and keep one fixture per version your banks send.

The Lynx guideline states that for camt.053 the status code must be BOOK. The Dutch Payments Association guideline for camt.053.001.02 (version 1.1, October 2013) defines BookgDt as the date the entry is posted on the account servicer's books and ValDt as the date the funds become available to the account owner for a credit, or stop being available for a debit. Match statement entries on booking date. Compute interest and available balance on value date; a business banking account that pays interest on balances misstates interest when a back-valued entry is applied on its booking date.

2. Build a synthetic statement with a batch and a return

The fixture uses synthetic identifiers and validates against the camt.053.001.08 XSD. Production statements and real account data stay out of the test environment.

XML
<?xml version="1.0" encoding="UTF-8"?>
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08">
  <BkToCstmrStmt>
    <GrpHdr>
      <MsgId>MSG-20270315-0074</MsgId>
      <CreDtTm>2027-03-15T23:10:00+01:00</CreDtTm>
    </GrpHdr>
    <Stmt>
      <Id>STMT-20270315-01</Id>
      <ElctrncSeqNb>74</ElctrncSeqNb>
      <CreDtTm>2027-03-15T23:10:00+01:00</CreDtTm>
      <Acct><Id><Othr><Id>ACCT-TEST-0001</Id></Othr></Id><Ccy>EUR</Ccy></Acct>
      <Bal>
        <Tp><CdOrPrtry><Cd>OPBD</Cd></CdOrPrtry></Tp>
        <Amt Ccy="EUR">10000.00</Amt><CdtDbtInd>CRDT</CdtDbtInd>
        <Dt><Dt>2027-03-15</Dt></Dt>
      </Bal>
      <Bal>
        <Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp>
        <Amt Ccy="EUR">10167.40</Amt><CdtDbtInd>CRDT</CdtDbtInd>
        <Dt><Dt>2027-03-15</Dt></Dt>
      </Bal>
      <TxsSummry>
        <TtlCdtNtries><NbOfNtries>2</NbOfNtries><Sum>2667.40</Sum></TtlCdtNtries>
        <TtlDbtNtries><NbOfNtries>1</NbOfNtries><Sum>2500.00</Sum></TtlDbtNtries>
      </TxsSummry>
      <!-- 1. Single credit: processor payout -->
      <Ntry>
        <Amt Ccy="EUR">1816.90</Amt>
        <CdtDbtInd>CRDT</CdtDbtInd>
        <Sts><Cd>BOOK</Cd></Sts>
        <BookgDt><Dt>2027-03-15</Dt></BookgDt>
        <ValDt><Dt>2027-03-15</Dt></ValDt>
        <AcctSvcrRef>BNK0315000001</AcctSvcrRef>
        <BkTxCd><Domn><Cd>PMNT</Cd><Fmly><Cd>RCDT</Cd><SubFmlyCd>ESCT</SubFmlyCd></Fmly></Domn></BkTxCd>
        <NtryDtls><TxDtls>
          <Refs><EndToEndId>PO-20270312-000412</EndToEndId></Refs>
          <Amt Ccy="EUR">1816.90</Amt><CdtDbtInd>CRDT</CdtDbtInd>
        </TxDtls></NtryDtls>
      </Ntry>
      <!-- 2. Batch debit: supplier payment run booked as one entry -->
      <Ntry>
        <Amt Ccy="EUR">2500.00</Amt>
        <CdtDbtInd>DBIT</CdtDbtInd>
        <Sts><Cd>BOOK</Cd></Sts>
        <BookgDt><Dt>2027-03-15</Dt></BookgDt>
        <ValDt><Dt>2027-03-15</Dt></ValDt>
        <AcctSvcrRef>BNK0315000002</AcctSvcrRef>
        <BkTxCd><Domn><Cd>PMNT</Cd><Fmly><Cd>ICDT</Cd><SubFmlyCd>ESCT</SubFmlyCd></Fmly></Domn></BkTxCd>
        <NtryDtls>
          <Btch>
            <PmtInfId>PMT-RUN-20270315-01</PmtInfId>
            <NbOfTxs>3</NbOfTxs>
            <TtlAmt Ccy="EUR">2500.00</TtlAmt>
            <CdtDbtInd>DBIT</CdtDbtInd>
          </Btch>
          <TxDtls><Refs><EndToEndId>INV-7781</EndToEndId></Refs><Amt Ccy="EUR">1200.00</Amt><CdtDbtInd>DBIT</CdtDbtInd></TxDtls>
          <TxDtls><Refs><EndToEndId>INV-7782</EndToEndId></Refs><Amt Ccy="EUR">850.50</Amt><CdtDbtInd>DBIT</CdtDbtInd></TxDtls>
          <TxDtls><Refs><EndToEndId>INV-7783</EndToEndId></Refs><Amt Ccy="EUR">449.50</Amt><CdtDbtInd>DBIT</CdtDbtInd></TxDtls>
        </NtryDtls>
      </Ntry>
      <!-- 3. Return of INV-7782: a credit that reverses the original debit -->
      <Ntry>
        <Amt Ccy="EUR">850.50</Amt>
        <CdtDbtInd>CRDT</CdtDbtInd>
        <RvslInd>true</RvslInd>
        <Sts><Cd>BOOK</Cd></Sts>
        <BookgDt><Dt>2027-03-15</Dt></BookgDt>
        <ValDt><Dt>2027-03-15</Dt></ValDt>
        <AcctSvcrRef>BNK0315000003</AcctSvcrRef>
        <BkTxCd><Domn><Cd>PMNT</Cd><Fmly><Cd>ICDT</Cd><SubFmlyCd>RRTN</SubFmlyCd></Fmly></Domn></BkTxCd>
        <NtryDtls><TxDtls>
          <Refs><EndToEndId>INV-7782</EndToEndId></Refs>
          <Amt Ccy="EUR">850.50</Amt><CdtDbtInd>CRDT</CdtDbtInd>
        </TxDtls></NtryDtls>
      </Ntry>
    </Stmt>
  </BkToCstmrStmt>
</Document>

The bank transaction codes come from the ISO 20022 Bank Transaction Code list (30 October 2023): PMNT/RCDT/ESCT is a received SEPA credit transfer, PMNT/ICDT/ESCT an issued one, and PMNT/ICDT/RRTN a reversal due to payment return. Balance check for the fixture: 10,000.00 plus 1,816.90, minus 2,500.00, plus 850.50 equals the closing booked balance of 10,167.40.

3. Check every statement before the matcher sees it

Run the checker on every bank file and every mutated fixture; malformed files produce a result instead of a crash.

Python
import sys
import xml.etree.ElementTree as ET
from decimal import Decimal, InvalidOperation


def local(tag):
    return tag.rsplit("}", 1)[-1]


def child(el, *path):
    for name in path:
        if el is None:
            return None
        el = next((c for c in el if local(c.tag) == name), None)
    return el


def children(el, name):
    return [] if el is None else [c for c in el if local(c.tag) == name]


def text(el, *path):
    node = child(el, *path)
    return node.text.strip() if node is not None and node.text else None


def signed(amount, indicator):
    # Amt is never negative (minInclusive 0); the sign comes only from CdtDbtInd.
    value = Decimal(amount)
    if indicator == "CRDT":
        return value
    if indicator == "DBIT":
        return -value
    raise ValueError(f"CdtDbtInd is {indicator!r}")


def one_balance(stmt, code):
    found = [b for b in children(stmt, "Bal") if text(b, "Tp", "CdOrPrtry", "Cd") == code]
    if len(found) != 1:
        raise ValueError(f"{len(found)} balances of type {code}")
    return signed(text(found[0], "Amt"), text(found[0], "CdtDbtInd"))


def check_statement(stmt):
    issues = []
    net = Decimal(0)
    for ntry in children(stmt, "Ntry"):
        ref = text(ntry, "AcctSvcrRef") or text(ntry, "NtryRef") or "no-ref"
        # camt.053.001.02: <Sts>BOOK</Sts>; camt.053.001.08: <Sts><Cd>BOOK</Cd></Sts>
        status = text(ntry, "Sts", "Cd") or text(ntry, "Sts")
        if status != "BOOK":
            issues.append((ref, f"status {status} in an end-of-day statement"))
        amount = signed(text(ntry, "Amt"), text(ntry, "CdtDbtInd"))
        net += amount  # RvslInd does not change the sign
        for dtls in children(ntry, "NtryDtls"):
            txs = children(dtls, "TxDtls")
            declared = text(dtls, "Btch", "NbOfTxs")
            if declared and not txs:
                issues.append((ref, f"batch of {declared} without TxDtls: match on Btch/PmtInfId"))
            elif declared and int(declared) != len(txs):
                issues.append((ref, f"NbOfTxs {declared}, TxDtls {len(txs)}"))
            if txs and all(text(tx, "Amt") for tx in txs):
                total = sum(signed(text(tx, "Amt"), text(tx, "CdtDbtInd") or text(ntry, "CdtDbtInd")) for tx in txs)
                if total != amount:
                    issues.append((ref, f"TxDtls sum {total}, entry {amount}"))
    opening, closing = one_balance(stmt, "OPBD"), one_balance(stmt, "CLBD")
    if opening + net != closing:
        issues.append(("balance", f"OPBD {opening} + entries {net} = {opening + net}, CLBD {closing}"))
    return issues


def main(path, previous_seq=None, previous_clbd=None):
    try:
        root = ET.parse(path).getroot()
    except (ET.ParseError, OSError) as exc:
        print(f"ERROR {path}: {exc}")
        return 2
    failed = False
    for stmt in root.iter():
        if local(stmt.tag) != "Stmt":
            continue
        sid = text(stmt, "Id") or "no-id"
        try:
            issues = check_statement(stmt)
            seq = text(stmt, "ElctrncSeqNb") or text(stmt, "LglSeqNb")
            if previous_seq is not None and seq != str(int(previous_seq) + 1):
                issues.append(("sequence", f"expected {int(previous_seq) + 1}, got {seq}"))
            if previous_clbd is not None and one_balance(stmt, "OPBD") != Decimal(previous_clbd):
                issues.append(("continuity", f"OPBD differs from previous CLBD {previous_clbd}"))
        except (ValueError, TypeError, InvalidOperation) as exc:
            issues = [("parse", str(exc))]
        failed = failed or bool(issues)
        for ref, message in issues:
            print(f"FAIL {sid} {ref}: {message}")
        if not issues:
            print(f"PASS {sid}")
    return 1 if failed else 0


if __name__ == "__main__":
    sys.exit(main(*sys.argv[1:4]))

python check_camt053.py stmt.xml 73 10000.00 also checks that sequence number 74 follows 73 and that the opening balance equals the previous closing balance. The fixture passes; with the return mis-signed as DBIT, the checker reports a balance of 8,466.40 against 10,167.40.

4. Reconcile batch entries at the level the ledger books them

Assert three things for each entry with a Btch block: NbOfTxs equals the number of TxDtls, the TxDtls amounts sum to the entry amount, and each EndToEndId closes exactly one ledger item. When the bank sends no TxDtls, match the entry on Btch/PmtInfId and TtlAmt against the payment run as a whole. The Lynx guideline allows entry details to be left out for settlements where details should stay confidential, and gives payroll as the example, so a payroll account needs this fixture even when other accounts never produce it.

5. Test reversals as their own direction

The ISO definition of RvslInd, as reproduced in the Dutch guideline, says that CRDT with a reversal indicator of true means the original operation was a debit, and DBIT with true means the original was a credit. The sign is already in CdtDbtInd. The Lynx guideline adds that in a reversal the creditor and debtor parties should not be swapped and stay aligned with the original credit transfer or direct debit. Code that derives the counterparty from the entry direction therefore picks your own customer as the counterparty of a returned payment. Link the return to the original payment through EndToEndId and assert that the original invoice reopens.

6. Map MT940 fields when both formats run in parallel

During a migration, run one business day through both feeds and compare the ledger postings. Our field mapping, built from the Dutch guideline and the Danske Bank MT940 format description (reviewed 23 March 2023, checked on 2026-09-22):

MT940 fieldcamt.053.001.08 elementDifference to test
:20: transaction referenceStmt/Id16 characters against 35
:28C: statement and sequence numberElctrncSeqNb or LglSeqNb, StmtPgntn/PgNbNumbering can restart in the new feed
:60F: opening balanceBal with code OPBDNone for a single page
:60M: intermediate opening balanceBal with OPBD and sub-type INTMPages after the first
:61: value date, 6 digitsNtry/ValDt/DtFull date in camt.053
:61: entry date, MMDD, optionalNtry/BookgDt/DtNo year in MT940
:61: mark C or DNtry/CdtDbtIndNone
:61: mark RC or RDCdtDbtInd DBIT or CRDT with RvslInd trueRD is a credit entry
:61: amount, comma decimalNtry/Amt with Ccy, dot decimalSeparator; the amount is never negative
:61: transaction type, such as NTRFNtry/BkTxCdDifferent code lists
:61: reference for the account owner, 16 charactersRefs/EndToEndId or Btch/PmtInfId, 35 charactersTruncation
:61: // bank reference, 16 charactersNtry/AcctSvcrRef, 35 charactersConfirm equal values with the bank
:86: information to the account ownerTxDtls/RmtInf, RltdPties, AddtlNtryInfFree text against structured data
:62F: closing balanceBal with code CLBDNone for a single page
:64: closing available balanceBal with code CLAVBilateral in the Lynx guideline

The fixture day in MT940, references cut to 16 characters:

Output
:20:STMT20270315-01
:25:ACCTTEST0001
:28C:74/1
:60F:C270315EUR10000,00
:61:2703150315C1816,90NTRFPO-20270312-0004//BNK0315000001
:86:PAYOUT PO-20270312-000412
:61:2703150315D2500,00NTRFPMT-RUN-20270315//BNK0315000002
:86:BATCH 3 PAYMENTS PMT-RUN-20270315-01
:61:2703150315RD850,50NTRFINV-7782//BNK0315000003
:86:RETURN INV-7782
:62F:C270315EUR10167,40
:64:C270315EUR10167,40

Pitfalls that pass a single-file test

Truncated references collide. Danske Bank's field 61 layout gives the reference for the account owner 16 characters and delivers any excess characters in the next subfield. PMT-RUN-20270315-01 and PMT-RUN-20270315-02 both become PMT-RUN-20270315, so a matcher keyed on that reference can assign the second payment run's debit to the first one and mark the wrong invoices paid. After cutover, items still open from the MT940 period carry the 16-character key, new entries carry the full 19-character PmtInfId, and an exact-match rule leaves both sides open. Test two same-day runs sharing the first 16 characters, and one open item across the cutover date.

Entry dates without a year. Field 61 carries the entry date as MMDD. The line :61:2712310103D410,00NTRFINV-9001//BNK1231000007 has a value date of 31 December 2027 and an entry date of 3 January. A parser that takes the year from the value date books the entry on 3 January 2027, a year early. Derive the year from the statement period, and keep a year-end fixture.

Gross and net batch booking. The Dutch guideline describes two bank practices for rejected payments in a batch of ten EUR 10 transfers with two rejects. Under the gross method the batch entry shows EUR 100 debit and the two rejects come back as separate credits. Under the net method the batch entry shows EUR 80 and the rejects arrive only in a pain.002 status report. A matcher built for one method fails on the other: it waits for credits that never come, or finds the batch EUR 20 short. Keep a fixture for each method your banks use.

Intermediate page balances. In the CBPR+ guideline, rules R12 to R15 require every page of a paginated statement to carry one OPBD and one CLBD, with sub-type INTM on the balances between pages. A loader that stores every CLBD as the day's closing balance records a mid-statement figure. Store only balances without INTM and check the balance equation per page.

Test matrix

FixtureExpected result
Statement from step 2 unchangedChecker passes; INV-7781 and INV-7783 stay paid; INV-7782 reopens
One TxDtls removed from the batchNbOfTxs failure and a sum failure on the batch entry
Return booked as DBIT with RvslInd trueBalance failure; return not linked
Batch without TxDtlsEntry matched on PmtInfId and total
Sequence number skips one statementSequence failure; missing day requested from the bank
Two runs sharing the first 16 characters, MT940 feedEach run matched to its own invoices
Year-end :61: line with MMDD in JanuaryBooking date in the new year
Gross batch with two rejects as creditsRejected invoices reopen
Three-page statement with INTM balancesOne daily closing balance stored
Same day in MT940 and camt.053Identical ledger postings from both feeds

The ledger and reconciliation testing service delivers these fixtures and checks as reconciliation test scenarios against your bank files, and runs them again whenever a bank changes the camt.053 version or its usage guideline.

Questions

What is camt.053?

camt.053 is the ISO 20022 BankToCustomerStatement message. An account servicing bank sends camt.053 to report the entries booked to one or more accounts and the balances of each account, as an end-of-cycle statement such as a daily statement. Version camt.053.001.08 carries opening and closing booked balances, one entry per booking, and optional transaction details for every payment inside a batch entry.

Scope your testing

Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.

Scope your testing