ISO 20022: Message Anatomy and Implementation
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.
Message Categories
ISO 20022 organizes messages into business domains:
| Prefix | Domain | Examples |
|---|---|---|
pain | Payment Initiation | Customer-to-bank instructions |
pacs | Payment Clearing & Settlement | Bank-to-bank transfers |
camt | Cash Management | Account statements, balance reports |
acmt | Account Management | Account opening, KYC |
auth | Authorities | Regulatory reporting |
seev | Securities Events | Dividends, 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.