Skip to main content
digital payment systems cryptography banking protocols and blockchain internals

ISO 20022: Message Anatomy and Implementation

7 min read Chapter 8 of 21

ISO 20022: Message Anatomy and Implementation

ISO 20022 is the universal financial messaging standard that’s replacing every legacy format in banking: SWIFT MT messages (from the 1970s), proprietary clearing house formats, and regional payment standards. By 2025, SWIFT required all cross-border payments to use ISO 20022. The US Fedwire, CHIPS, and the European TARGET2 have already migrated.

For payment engineers, this means you need to parse, construct, and validate ISO 20022 XML messages as fluently as you handle JSON APIs.

ISO 20022 Message Structure

Message Categories

ISO 20022 organizes messages into business domains:

PrefixDomainExamples
painPayment InitiationCustomer-to-bank instructions
pacsPayment Clearing & SettlementBank-to-bank transfers
camtCash ManagementAccount statements, balance reports
acmtAccount ManagementAccount opening, KYC
authAuthoritiesRegulatory reporting
seevSecurities EventsDividends, corporate actions

The Message You’ll Use Most: pacs.008

pacs.008.001.10 (FI to FI Customer Credit Transfer) is the workhorse of cross-border payments. When Bank A transfers funds to Bank B on behalf of their respective customers, this is the message that flows through the payment network.

from dataclasses import dataclass, field
from datetime import datetime, date
from decimal import Decimal
from typing import Optional
import xml.etree.ElementTree as ET

# ISO 20022 namespace
NS = "urn:iso:std:iso:20022:tech:xsd:pacs.008.001.10"

@dataclass
class PartyIdentification:
    name: str
    bic: Optional[str] = None
    lei: Optional[str] = None
    address_country: Optional[str] = None

@dataclass
class AccountIdentification:
    iban: Optional[str] = None
    other_id: Optional[str] = None  # For non-IBAN accounts

@dataclass
class CreditTransferTransaction:
    """
    A single credit transfer within a pacs.008 message.
    A message can contain multiple transactions.
    """
    instruction_id: str         # Unique ID assigned by instructing agent
    end_to_end_id: str          # End-to-end reference (travels the full chain)
    uetr: str                   # Unique End-to-end Transaction Reference (UUID)
    amount: Decimal
    currency: str               # ISO 4217 (EUR, USD, GBP)
    settlement_date: date
    charge_bearer: str          # SHAR, DEBT, CRED, SLEV
    
    debtor: PartyIdentification
    debtor_account: AccountIdentification
    debtor_agent: PartyIdentification
    
    creditor: PartyIdentification
    creditor_account: AccountIdentification
    creditor_agent: PartyIdentification
    
    remittance_info: Optional[str] = None

@dataclass
class Pacs008Message:
    """
    pacs.008 — FI to FI Customer Credit Transfer
    
    This message is sent by the debtor's bank (or an intermediary)
    to the creditor's bank to transfer funds.
    """
    message_id: str
    creation_datetime: datetime
    number_of_transactions: int
    settlement_method: str      # INDA, INGA, COVE, CLRG
    settlement_date: date
    
    instructing_agent: PartyIdentification
    instructed_agent: PartyIdentification
    
    transactions: list[CreditTransferTransaction] = field(default_factory=list)


def build_pacs008(msg: Pacs008Message) -> str:
    """
    Construct a pacs.008.001.10 XML message from structured data.
    
    In production, use a library that validates against the XSD schema.
    This implementation shows the exact XML structure the message requires.
    """
    root = ET.Element("Document", xmlns=NS)
    fi_to_fi = ET.SubElement(root, "FIToFICstmrCdtTrf")
    
    # Group Header
    grp_hdr = ET.SubElement(fi_to_fi, "GrpHdr")
    ET.SubElement(grp_hdr, "MsgId").text = msg.message_id
    ET.SubElement(grp_hdr, "CreDtTm").text = msg.creation_datetime.isoformat()
    ET.SubElement(grp_hdr, "NbOfTxs").text = str(msg.number_of_transactions)
    
    sttlm_inf = ET.SubElement(grp_hdr, "SttlmInf")
    ET.SubElement(sttlm_inf, "SttlmMtd").text = msg.settlement_method
    
    # Instructing and Instructed Agents
    instg = ET.SubElement(grp_hdr, "InstgAgt")
    fin_instn = ET.SubElement(instg, "FinInstnId")
    ET.SubElement(fin_instn, "BICFI").text = msg.instructing_agent.bic
    
    instd = ET.SubElement(grp_hdr, "InstdAgt")
    fin_instn2 = ET.SubElement(instd, "FinInstnId")
    ET.SubElement(fin_instn2, "BICFI").text = msg.instructed_agent.bic
    
    # Credit Transfer Transactions
    for txn in msg.transactions:
        cdt_trf = ET.SubElement(fi_to_fi, "CdtTrfTxInf")
        
        # Payment Identification
        pmt_id = ET.SubElement(cdt_trf, "PmtId")
        ET.SubElement(pmt_id, "InstrId").text = txn.instruction_id
        ET.SubElement(pmt_id, "EndToEndId").text = txn.end_to_end_id
        ET.SubElement(pmt_id, "UETR").text = txn.uetr
        
        # Interbank Settlement Amount
        amt = ET.SubElement(cdt_trf, "IntrBkSttlmAmt", Ccy=txn.currency)
        amt.text = str(txn.amount)
        
        ET.SubElement(cdt_trf, "IntrBkSttlmDt").text = txn.settlement_date.isoformat()
        ET.SubElement(cdt_trf, "ChrgBr").text = txn.charge_bearer
        
        # Debtor
        dbtr = ET.SubElement(cdt_trf, "Dbtr")
        ET.SubElement(dbtr, "Nm").text = txn.debtor.name
        
        dbtr_acct = ET.SubElement(cdt_trf, "DbtrAcct")
        dbtr_id = ET.SubElement(dbtr_acct, "Id")
        ET.SubElement(dbtr_id, "IBAN").text = txn.debtor_account.iban
        
        dbtr_agt = ET.SubElement(cdt_trf, "DbtrAgt")
        dbtr_fi = ET.SubElement(dbtr_agt, "FinInstnId")
        ET.SubElement(dbtr_fi, "BICFI").text = txn.debtor_agent.bic
        
        # Creditor
        cdtr = ET.SubElement(cdt_trf, "Cdtr")
        ET.SubElement(cdtr, "Nm").text = txn.creditor.name
        
        cdtr_acct = ET.SubElement(cdt_trf, "CdtrAcct")
        cdtr_id = ET.SubElement(cdtr_acct, "Id")
        ET.SubElement(cdtr_id, "IBAN").text = txn.creditor_account.iban
        
        cdtr_agt = ET.SubElement(cdt_trf, "CdtrAgt")
        cdtr_fi = ET.SubElement(cdtr_agt, "FinInstnId")
        ET.SubElement(cdtr_fi, "BICFI").text = txn.creditor_agent.bic
        
        # Remittance Information
        if txn.remittance_info:
            rmt_inf = ET.SubElement(cdt_trf, "RmtInf")
            ET.SubElement(rmt_inf, "Ustrd").text = txn.remittance_info
    
    ET.indent(root)
    return ET.tostring(root, encoding="unicode", xml_declaration=True)


def parse_pacs008(xml_string: str) -> Pacs008Message:
    """
    Parse a pacs.008 XML message into structured data.
    
    Production parsers must:
    1. Validate against the XSD schema
    2. Check mandatory fields
    3. Validate IBAN check digits
    4. Validate BIC format (8 or 11 characters)
    5. Check UETR format (UUID v4)
    """
    root = ET.fromstring(xml_string)
    
    # Handle namespace
    ns = {"ns": NS}
    fi_to_fi = root.find("ns:FIToFICstmrCdtTrf", ns)
    
    grp_hdr = fi_to_fi.find("ns:GrpHdr", ns)
    
    msg = Pacs008Message(
        message_id=grp_hdr.find("ns:MsgId", ns).text,
        creation_datetime=datetime.fromisoformat(
            grp_hdr.find("ns:CreDtTm", ns).text
        ),
        number_of_transactions=int(grp_hdr.find("ns:NbOfTxs", ns).text),
        settlement_method=grp_hdr.find(
            "ns:SttlmInf/ns:SttlmMtd", ns
        ).text,
        settlement_date=date.today(),
        instructing_agent=PartyIdentification(
            name="",
            bic=grp_hdr.find("ns:InstgAgt/ns:FinInstnId/ns:BICFI", ns).text
        ),
        instructed_agent=PartyIdentification(
            name="",
            bic=grp_hdr.find("ns:InstdAgt/ns:FinInstnId/ns:BICFI", ns).text
        ),
    )
    
    for cdt_trf in fi_to_fi.findall("ns:CdtTrfTxInf", ns):
        pmt_id = cdt_trf.find("ns:PmtId", ns)
        amt_elem = cdt_trf.find("ns:IntrBkSttlmAmt", ns)
        
        txn = CreditTransferTransaction(
            instruction_id=pmt_id.find("ns:InstrId", ns).text,
            end_to_end_id=pmt_id.find("ns:EndToEndId", ns).text,
            uetr=pmt_id.find("ns:UETR", ns).text,
            amount=Decimal(amt_elem.text),
            currency=amt_elem.get("Ccy"),
            settlement_date=date.fromisoformat(
                cdt_trf.find("ns:IntrBkSttlmDt", ns).text
            ),
            charge_bearer=cdt_trf.find("ns:ChrgBr", ns).text,
            debtor=PartyIdentification(
                name=cdt_trf.find("ns:Dbtr/ns:Nm", ns).text
            ),
            debtor_account=AccountIdentification(
                iban=cdt_trf.find("ns:DbtrAcct/ns:Id/ns:IBAN", ns).text
            ),
            debtor_agent=PartyIdentification(
                name="",
                bic=cdt_trf.find("ns:DbtrAgt/ns:FinInstnId/ns:BICFI", ns).text
            ),
            creditor=PartyIdentification(
                name=cdt_trf.find("ns:Cdtr/ns:Nm", ns).text
            ),
            creditor_account=AccountIdentification(
                iban=cdt_trf.find("ns:CdtrAcct/ns:Id/ns:IBAN", ns).text
            ),
            creditor_agent=PartyIdentification(
                name="",
                bic=cdt_trf.find("ns:CdtrAgt/ns:FinInstnId/ns:BICFI", ns).text
            ),
        )
        msg.transactions.append(txn)
    
    return msg

MT to MX Migration: What Changes

SWIFT’s legacy MT (Message Type) format uses a fixed-field text format designed in the 1970s. The migration to MX (ISO 20022 XML) isn’t just a format change — it’s a structural shift that enables richer data.

MT103 → pacs.008 Field Mapping

MT103 (Single Customer Credit Transfer)
=========================================
:20:  Transaction Reference       → PmtId/InstrId
:23B: Bank Operation Code         → (implicit in message type)
:32A: Value Date/Currency/Amount  → IntrBkSttlmDt + IntrBkSttlmAmt
:33B: Currency/Instructed Amount  → InstdAmt
:50K: Ordering Customer           → Dbtr + DbtrAcct
:52A: Ordering Institution        → DbtrAgt
:53A: Sender's Correspondent      → InstgAgt
:56A: Intermediary                → IntrmyAgt1
:57A: Account With Institution    → CdtrAgt
:59:  Beneficiary Customer        → Cdtr + CdtrAcct
:70:  Remittance Information      → RmtInf/Ustrd
:71A: Details of Charges          → ChrgBr
:72:  Sender to Receiver Info     → InstrForCdtrAgt / InstrForNxtAgt

What MX adds that MT103 cannot express:
- LEI (Legal Entity Identifier) for KYC/AML
- Structured remittance data (invoice numbers, tax IDs)
- Purpose codes (SALA = salary, SUPP = supplier payment)
- Regulatory reporting fields
- UETR for end-to-end transaction tracking

The UETR (Unique End-to-end Transaction Reference) is particularly important. It’s a UUID that stays with the payment from initiation to final credit, across all intermediary banks. Before UETR, tracking a cross-border payment through multiple correspondent banks required manual investigation. Now, any party in the chain can query the payment status using the UETR through SWIFT’s gpi (Global Payments Innovation) tracker.

import uuid

def generate_uetr() -> str:
    """
    Generate a UETR (Unique End-to-end Transaction Reference).
    
    Format: UUID v4, lowercase, with hyphens.
    Example: "eb6305c9-1f7f-49de-aef2-cd4973afdc73"
    
    This identifier is assigned by the first agent in the payment
    chain and must be preserved by every subsequent agent.
    """
    return str(uuid.uuid4())

def validate_iban(iban: str) -> bool:
    """
    Validate an IBAN using the MOD-97 check.
    
    ISO 13616: Move the first 4 characters to the end,
    convert letters to numbers (A=10, B=11, ..., Z=35),
    and verify that the result mod 97 equals 1.
    """
    # Remove spaces and convert to uppercase
    iban = iban.replace(" ", "").upper()
    
    if len(iban) < 5:
        return False
    
    # Move first 4 chars to end
    rearranged = iban[4:] + iban[:4]
    
    # Convert letters to numbers
    numeric = ""
    for char in rearranged:
        if char.isdigit():
            numeric += char
        elif char.isalpha():
            numeric += str(ord(char) - ord('A') + 10)
        else:
            return False
    
    # MOD-97 check
    return int(numeric) % 97 == 1

def validate_bic(bic: str) -> bool:
    """
    Validate a BIC (Business Identifier Code) / SWIFT code.
    
    Format: BBBB CC LL [bbb]
    - BBBB: Bank code (4 letters)
    - CC: Country code (2 letters, ISO 3166-1)
    - LL: Location code (2 alphanumeric)
    - bbb: Branch code (3 alphanumeric, optional — 'XXX' = head office)
    """
    bic = bic.upper().strip()
    if len(bic) not in (8, 11):
        return False
    
    # Bank code: 4 letters
    if not bic[:4].isalpha():
        return False
    
    # Country code: 2 letters (should be valid ISO 3166-1)
    if not bic[4:6].isalpha():
        return False
    
    # Location code: 2 alphanumeric
    if not bic[6:8].isalnum():
        return False
    
    # Branch code: 3 alphanumeric (if present)
    if len(bic) == 11 and not bic[8:11].isalnum():
        return False
    
    return True

The transition from MT to MX is the largest infrastructure change in banking since SWIFT’s founding in 1973. Every bank, payment processor, and clearing house must update their message parsing, routing, validation, and archival systems. For payment engineers, fluency in ISO 20022 XML is no longer optional — it’s the language the banking system speaks.