From 15 November 2026, a pain.001 or pacs.008 message that carries only free-text address lines stops being accepted. You need structured fields, or the hybrid form with at least town and country as separate elements. If you are reading this on or after that date, treat it as a post-cutover checklist: the code below is what I would put in front of a payment gateway right now, and the last section covers what to watch in the first weeks.
I worked on this for a payments platform over several months, and the XML turned out to be the smaller problem. What actually hurt was an old customer table that nobody had ever cleaned.
I start with a plain explanation, then move toward architecture and Spring Boot code. Newer readers can stop after the first sections; people who already run payment gateways can jump to the parser. From the architecture onward I assume you know what an outbox and an idempotent consumer are.
The plain-language version
ISO 20022 is a message standard for payments, expressed in XML, which is a text format built from nested tags. Because every field has a fixed name and meaning, a bank in Frankfurt and a bank in Singapore read the same message the same way.
Two message types matter here. The names below are the official ones from the ISO 20022 catalogue:
- pain.001, Customer Credit Transfer Initiation: your customer (or your corporate client) sends it to their bank to ask for payments. The version I worked with was
pain.001.001.09; plenty of banks in the wild still accept.03, and the address block differs between them, so check which one your bank expects. - pacs.008, FI to FI Customer Credit Transfer: the bank-to-bank message that actually moves the money. The CBPR+ usage guideline I used targets
pacs.008.001.08.
Both carry the payer's and the payee's postal address. For years that could be free text in a few AdrLine elements:
<PstlAdr> <AdrLine>Hauptstrasse 12</AdrLine> <AdrLine>10115 Berlin</AdrLine> <AdrLine>Germany</AdrLine> </PstlAdr> A person reads that without effort. Sanctions screening, name matching and routing software has to work out where the street stops and the town starts. The structured alternative names each part:
<PstlAdr> <StrtNm>Hauptstrasse</StrtNm> <BldgNb>12</BldgNb> <PstCd>10115</PstCd> <TwnNm>Berlin</TwnNm> <Ctry>DE</Ctry> </PstlAdr> The tag names are short on purpose: StrtNm is street name, BldgNb building number, PstCd postcode, TwnNm town name, Ctry the two-letter country code. The full element list in the schema is longer, and that matters later: Dept, SubDept, BldgNm, Flr, PstBx, Room, TwnLctnNm, DstrctNm and CtrySubDvsn all exist.
Which shapes survive the deadline
An address can arrive in three forms, and only two of them survive:
- Fully structured: every part in its own element. This is where you want to end up.
- Hybrid:
TwnNmandCtryare real elements, and up to twoAdrLineentries of at most 70 characters each carry whatever remains. This is the temporary bridge for data you cannot split yet. - Unstructured (rejected): only
AdrLine. This is the form that no longer passes.
For cross-border traffic the rule comes from the CBPR+ usage guidelines (Cross-Border Payments and Reporting Plus), maintained for Swift's ISO 20022 migration and published through Swift MyStandards. For SEPA the equivalent sits in the EPC scheme rulebooks. I would not quote field lengths or the affected parties from memory: open the exact guideline or rulebook version your scheme membership points to and copy the rule from there. Which parties are covered, and the length limits, differ between schemes and versions.
Why the XML is rarely the failure point
Before writing any code I audited the existing system, expecting template bugs. What I found was this:
- One
addresscolumn, 255 characters, populated by a signup form that dated from 2016 and accepted anything. - Country stored as whatever the user typed:
"Deutschland","Germany","GER","DE "with a trailing space. - Lines pasted from PDF invoices, with a postcode on line 1, the town on line 3 and a phone number in between.
I am deliberately not quoting failure percentages from that client, because they are theirs and because your numbers will differ. The useful exercise is to run your own table through a parser and count. In my case the country column alone explained most of the rows that could not be resolved on the first pass.
Feed "c/o Mueller, Postfach 77 / 50667 Köln" into a flawless XML serializer and you still get a wrong or rejected message. So the repair has to happen before the message builder, in a translation layer between the legacy data and the new message model.
Build, buy, or fix the source
Three alternatives deserve a straight answer before you commit to writing a parser.
- Fix the data at source. Migrate the column into structured fields and change the signup form. This is the right long-term move and I recommend it. It does not help with ten years of existing rows or with payees entered by third parties, so it complements a parser rather than replacing it.
- Buy or embed a parser.
libpostalis open source and statistical, covers many countries, but is a heavy native dependency with a large model download and gives you probabilities your compliance reviewer cannot trace. Google Address Validation and national postcode APIs give excellent results for the addresses they know, but each lookup sends customer personal data to a third party, which needs a data-processing and cross-border-transfer review that can take longer than writing the code. They also add latency and per-call cost on the payment path. - Write a rule-based parser for the countries you actually send to. It is narrow and needs maintenance, but every decision is a readable rule.
I chose the third for the first release, kept a hook so a library can be plugged in as a second opinion, and scheduled the source-data migration in parallel. In DDD vocabulary this is an anti-corruption layer; in practice it is simply a boundary where messy input gets translated and uncertainty gets labelled.
The architecture
The service runs four stages in sequence:
- Parse free text into a
StructuredAddressplus a confidence score. - Validate the outbound message against the structured and hybrid rules.
- Repair low-confidence cases through a queue, without losing or duplicating a payment.
- Measure everything so the cutover is visible.
I will go through each with code.
Stage 1: parser and confidence score
The parser never hands back a bare address. It hands back the address together with a confidence value and the reasons that lowered it, because a parse nobody can interrogate is something an operator ends up trusting blindly.
The model first. A thin record fails on real data, so this one carries the schema's building-level fields:
public record StructuredAddress( String department, String subDepartment, String streetName, String buildingNumber, String buildingName, String floor, String room, String postBox, String postCode, String townName, String districtName, String countrySubDivision, String countryCode, List<String> residualLines) { public boolean hasMandatoryHybridFields() { return notBlank(townName) && countryCode != null && countryCode.matches("[A-Z]{2}"); } private static boolean notBlank(String s) { return s != null && !s.isBlank(); } } public record ParseResult(StructuredAddress address, double confidence, List<String> reasons) {} The parser is rule-based and selects a strategy per country. I avoided a machine-learning model in the first release because a compliance reviewer can read a regex and cannot audit a probability distribution.
@Component public class PostalAddressParser { private final CountryResolver countries; private final Map<String, AddressStrategy> strategies; public PostalAddressParser(CountryResolver countries, List<AddressStrategy> all) { this.countries = countries; this.strategies = all.stream() .collect(Collectors.toMap(AddressStrategy::countryCode, Function.identity())); } public ParseResult parse(List<String> rawLines, String countryHint) { List<String> reasons = new ArrayList<>(); double confidence = 1.0; String country = countries.toIso2(countryHint) .or(() -> countries.sniffFromLines(rawLines)) .orElse(null); if (country == null) { reasons.add("COUNTRY_UNRESOLVED"); confidence -= 0.5; } AddressStrategy strategy = strategies.getOrDefault(country, strategies.get("GENERIC")); AddressDraft d = strategy.apply(rawLines.stream().map(String::strip).toList(), countries); if (d.town() == null) { reasons.add("TOWN_MISSING"); confidence -= 0.4; } if (d.street() == null && d.postBox() == null) { reasons.add("STREET_UNSPLIT"); confidence -= 0.2; } if (d.postCode() != null && !strategy.postCodeValid(d.postCode())) { reasons.add("POSTCODE_FORMAT"); confidence -= 0.15; } if (!d.residual().isEmpty()) { reasons.add("RESIDUAL_LINES"); confidence -= 0.1; } return new ParseResult(d.toAddress(country), Math.max(confidence, 0.0), reasons); } } The weights (0.5, 0.4, 0.2, 0.15, 0.1) are not derived from anything clever. A missing town or an unresolved country makes a message unusable under the hybrid rule, so those two are penalised hard enough to push the score under the gate threshold on their own. Smaller defects such as leftover lines only nudge the score down, and two of them together should still land a parse in the repair queue. I tuned them by replaying a sample of real addresses and reading the borderline cases by hand.
Country conventions are where it gets real
Postcode position and house-number format change per country, which is why one regex fails:
@Component class GermanStrategy implements AddressStrategy { private static final Pattern PLZ_TOWN = Pattern.compile("^(\\d{5})\\s+(.+)$"); private static final Pattern STREET_NO = Pattern.compile("^(.+?(?:str\\.|straße|strasse|weg|platz|allee)?)\\s+(\\d+\\s?[a-zA-Z]?(?:[-/]\\d+)?)$", Pattern.CASE_INSENSITIVE); private static final Pattern POSTFACH = Pattern.compile("^Postfach\\s+(\\d+)$", Pattern.CASE_INSENSITIVE); public String countryCode() { return "DE"; } public AddressDraft apply(List<String> lines, CountryResolver c) { AddressDraft d = new AddressDraft(); for (String line : lines) { Matcher pf = POSTFACH.matcher(line); Matcher pt = PLZ_TOWN.matcher(line); Matcher sn = STREET_NO.matcher(line); if (pf.matches()) d.postBox(pf.group(1)); else if (d.town() == null && pt.matches()) { d.postCode(pt.group(1)); d.town(pt.group(2)); } else if (d.street() == null && sn.matches()) { d.street(sn.group(1)); d.buildingNumber(sn.group(2)); } else if (!c.isCountryName(line)) d.residual(line); } return d; } public boolean postCodeValid(String pc) { return pc.matches("\\d{5}"); } } The Dutch case needs more care. Postcodes are four digits, a space, and two letters (1011 AB Amsterdam), and house numbers often carry an addition, as in Keizersgracht 12 bis or Prinsengracht 263-II. A German-style number pattern would drop the suffix into the street name.
@Component class DutchStrategy implements AddressStrategy { private static final Pattern PC_TOWN = Pattern.compile("^(\\d{4}\\s?[A-Z]{2})\\s+(.+)$"); // number, then optional addition: "12", "12a", "12 bis", "263-II", "12 3hg" private static final Pattern STREET_NO = Pattern.compile( "^(.+?)\\s+(\\d+)\\s*([a-zA-Z]{1,3}|[-/]\\w{1,4}|bis|ter|\\d{1,2}\\s?hg)?$", Pattern.CASE_INSENSITIVE); public String countryCode() { return "NL"; } public AddressDraft apply(List<String> lines, CountryResolver c) { AddressDraft d = new AddressDraft(); for (String line : lines) { Matcher pt = PC_TOWN.matcher(line); Matcher sn = STREET_NO.matcher(line); if (d.town() == null && pt.matches()) { d.postCode(pt.group(1).replace(" ", "")); // keep canonical form: 1011AB d.town(pt.group(2)); } else if (d.street() == null && sn.matches()) { d.street(sn.group(1)); d.buildingNumber((sn.group(2) + (sn.group(3) == null ? "" : " " + sn.group(3))).trim()); } else if (!c.isCountryName(line)) { d.residual(line); } } return d; } public boolean postCodeValid(String pc) { return pc.matches("\\d{4}[A-Z]{2}"); } } Singapore is different again. Addresses use a block number (Blk 123), a street, a unit in #04-56 form (floor 04, unit 56) and a six-digit postcode, and there is no state or province. Mapping that onto ISO 20022 means the block goes into BldgNb, the unit splits into Flr and Room, and the town is often just Singapore:
@Component class SingaporeStrategy implements AddressStrategy { private static final Pattern BLK_STREET = Pattern.compile("^(?:Blk|Block)\\s+(\\d+[A-Z]?)\\s+(.+)$", Pattern.CASE_INSENSITIVE); private static final Pattern UNIT = Pattern.compile("^#(\\d{1,3})-(\\d{1,5}[A-Z]?)$"); private static final Pattern SG_POSTCODE = Pattern.compile("^(?:Singapore\\s+)?(\\d{6})$", Pattern.CASE_INSENSITIVE); public String countryCode() { return "SG"; } public AddressDraft apply(List<String> lines, CountryResolver c) { AddressDraft d = new AddressDraft(); for (String line : lines) { Matcher b = BLK_STREET.matcher(line); Matcher u = UNIT.matcher(line); Matcher p = SG_POSTCODE.matcher(line); if (b.matches()) { d.buildingNumber(b.group(1)); d.street(b.group(2)); } else if (u.matches()) { d.floor(u.group(1)); d.room(u.group(2)); } else if (p.matches()) { d.postCode(p.group(1)); d.town("Singapore"); } else if (!c.isCountryName(line)) d.residual(line); } return d; } public boolean postCodeValid(String pc) { return pc.matches("\\d{6}"); } } Two traps I hit. In the Netherlands the postcode precedes the town while in the UK it comes last, so you cannot share the line-scan order between countries. And everything the strategies cannot place lands in residualLines, which is what the hybrid form's two AdrLine entries are for, so unplaceable text is preserved rather than thrown away.
Tests that pin the behaviour
Without tests a parser like this drifts. I keep a table of real-looking cases per country:
@ParameterizedTest @CsvSource(delimiter = '|', textBlock = """ DE | Hauptstrasse 12;10115 Berlin | Hauptstrasse | 12 | 10115 | Berlin NL | Keizersgracht 12 bis;1015 CJ Amsterdam | Keizersgracht| 12 bis | 1015CJ | Amsterdam SG | Blk 123 Ang Mo Kio Ave 6;#04-56;Singapore 560123 | Ang Mo Kio Ave 6 | 123 | 560123 | Singapore """) void parsesCountryConventions(String cc, String lines, String street, String no, String pc, String town) { ParseResult r = parser.parse(List.of(lines.split(";")), cc); assertThat(r.address().streetName()).isEqualTo(street.strip()); assertThat(r.address().buildingNumber()).isEqualTo(no.strip()); assertThat(r.address(Source:https://dev.to/krishna_y/iso-20022-structured-address-validator-in-spring-boot-33d6
