DEV Community

Krishna Yadav
Krishna Yadav

Posted on Originally published at krishnakky.com

ISO 20022 Structured Address Validator in Spring Boot

Illustration of a free-text postal address block being split into labelled structured fields and passing through a validation gate before a payment message is sent

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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:

  1. Fully structured: every part in its own element. This is where you want to end up.
  2. Hybrid: TwnNm and Ctry are real elements, and up to two AdrLine entries of at most 70 characters each carry whatever remains. This is the temporary bridge for data you cannot split yet.
  3. 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 address column, 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. libpostal is 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

Architecture diagram of the address anti-corruption layer: legacy address records enter a parser, a confidence check routes good parses to the pain.001 and pacs.008 validator and doubtful ones to a repair queue via a transactional outbox, with rejection metrics exported to monitoring

The service runs four stages in sequence:

  1. Parse free text into a StructuredAddress plus a confidence score.
  2. Validate the outbound message against the structured and hybrid rules.
  3. Repair low-confidence cases through a queue, without losing or duplicating a payment.
  4. 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) {}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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}"); }
}
Enter fullscreen mode Exit fullscreen mode

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}"); }
}
Enter fullscreen mode Exit fullscreen mode

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}"); }
}
Enter fullscreen mode Exit fullscreen mode

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().postCode()).isEqualTo(pc.strip());
    assertThat(r.address().townName()).isEqualTo(town.strip());
    assertThat(r.confidence()).isGreaterThanOrEqualTo(0.85);
}

@Test
void ambiguousInputFallsBelowGateThreshold() {
    ParseResult r = parser.parse(List.of("c/o Mueller", "Tel 0221 555 0101"), null);
    assertThat(r.reasons()).contains("COUNTRY_UNRESOLVED", "TOWN_MISSING");
    assertThat(r.confidence()).isLessThan(0.85);
}
Enter fullscreen mode Exit fullscreen mode

The second test matters more than the first. A parser that scores garbage low is what makes the repair queue work at all.

The reasons list earns its keep when an operator opens a repair ticket: TOWN_MISSING tells them what to fix, confidence 0.6 does not. The same strings later become metric labels.

Stage 2: validating the outbound message

Parsing repairs data; validation guards the exit. A batch job somewhere will always build pain.001 by string concatenation and bypass your parser, so the last gate inspects the message, not the domain objects.

I validate in two layers. The official XSD enforces schema shape. A second set of business rules covers what an XSD cannot express, for example that hybrid allows at most two AdrLine entries of 70 characters and needs TwnNm and Ctry.

@Component
public class PostalAddressRules {

    public List<Violation> check(Element pstlAdr, String path) {
        List<Violation> out = new ArrayList<>();
        boolean hasStructured = hasAny(pstlAdr, "StrtNm", "BldgNb", "PstCd", "TwnNm", "Ctry");
        List<String> adrLines = texts(pstlAdr, "AdrLine");

        if (!hasStructured) {
            out.add(new Violation(path, "ADDR_UNSTRUCTURED",
                    "Only AdrLine present; TwnNm and Ctry are mandatory from 2026-11-15"));
            return out;
        }
        require(pstlAdr, "TwnNm", path, out);
        require(pstlAdr, "Ctry", path, out);

        if (adrLines.size() > 2)
            out.add(new Violation(path, "ADDR_HYBRID_TOO_MANY_LINES", "Max 2 AdrLine in hybrid"));
        adrLines.stream().filter(l -> l.length() > 70).forEach(l ->
            out.add(new Violation(path, "ADDR_LINE_TOO_LONG", "AdrLine exceeds 70 chars")));

        text(pstlAdr, "Ctry").filter(c -> !c.matches("[A-Z]{2}")).ifPresent(c ->
            out.add(new Violation(path, "ADDR_BAD_COUNTRY", "Ctry must be ISO 3166 alpha-2")));
        text(pstlAdr, "TwnNm").filter(t -> t.length() > 35).ifPresent(t ->
            out.add(new Violation(path, "ADDR_TOWN_TOO_LONG", "TwnNm exceeds 35 chars")));
        return out;
    }

    private void require(Element e, String tag, String path, List<Violation> out) {
        if (text(e, tag).filter(s -> !s.isBlank()).isEmpty())
            out.add(new Violation(path, "ADDR_MISSING_" + tag.toUpperCase(), tag + " is mandatory"));
    }
    // hasAny, texts, text: small DOM helpers, omitted for brevity
}
Enter fullscreen mode Exit fullscreen mode

Two notes. Run the check on every PstlAdr in the message: debtor, creditor, ultimate debtor, ultimate creditor and any agents that carry an address. Ultimate parties are the ones people forget. And fail closed: a message with violations never reaches the transport layer, and the violation codes are stored so a decision can be reproduced later. That stored trail doubles as audit evidence, an idea I developed in DORA and NIS2 compliance evidence as code. I keep length limits in configuration instead of constants, because they varied between message versions while I was testing.

Stage 3: repair queue and outbox

Payments engineers should read this part twice. When confidence is low, guessing is the wrong move: a wrong address that gets through is worse than a late payment, since sanctions screening relies on it. So the payment is parked, a person fixes the address, and the payment resumes.

The risk is the dual write. Save the parked payment, then publish "needs repair" to a broker as two separate steps, and a crash between them leaves a payment nobody knows about, or a retry that opens two tickets. The transactional outbox removes that gap: the business change and the outgoing event commit in the same database transaction and a separate relay publishes the event. I covered the pattern, including idempotent consumers, in the transactional outbox guide for payment systems, so below is only what is specific to addresses.

@Service
public class AddressGateService {

    private static final double THRESHOLD = 0.85;
    private final PostalAddressParser parser;
    private final PaymentRepository payments;
    private final OutboxRepository outbox;
    private final MeterRegistry meters;

    // constructor omitted

    @Transactional
    public GateDecision admit(PaymentInstruction p) {
        ParseResult r = parser.parse(p.creditorRawLines(), p.creditorCountry());

        if (r.confidence() >= THRESHOLD && r.address().hasMandatoryHybridFields()) {
            p.attachAddress(r.address());
            p.markReadyToSend();
            payments.save(p);
            meters.counter("address.gate", "outcome", "accepted").increment();
            return GateDecision.READY;
        }

        p.markPendingRepair(r.reasons());
        payments.save(p);
        outbox.save(OutboxEvent.of(
                "address-repair-requested",
                p.id().toString(),                       // aggregate key = ordering
                Map.of("paymentId", p.id(), "reasons", r.reasons(),
                       "dedupeKey", p.id() + ":addr:" + p.version())));
        r.reasons().forEach(reason ->
            meters.counter("address.gate", "outcome", "repair", "reason", reason).increment());
        return GateDecision.PARKED;
    }
}
Enter fullscreen mode Exit fullscreen mode

On the 0.85 threshold: it comes out of the weights above. A parse missing only the street scores 0.8 and goes to repair; one with a clean town, postcode and street scores 1.0; a lone leftover line scores 0.9 and passes. I moved it once, from 0.80 to 0.85, after reading parked samples and finding that "street unsplit" cases were mostly addresses where the street really was missing, not parser misses. Treat the number as something to calibrate on your own data, not copy.

Three details matter more than they look:

  • One transaction. The payment's new state and the outbox row commit together or not at all.
  • A dedupe key from payment id plus version. The consumer that creates repair tickets skips a key it has seen, so a relay retry cannot produce a second ticket.
  • The same gate resumes the payment. When an operator submits a corrected address, the repair handler runs the validation again before the payment returns to READY. Human input gets no free pass; a mistyped country code is still a mistyped country code.

If your platform already has a streaming backbone, repair tickets also fit queue-style consumption well. I explored that in my notes on Kafka share groups for payment pipelines.

Stage 4: watching the cutover

Failures here are quiet. Nothing crashes; a share of payments comes back, seconds or hours later, with a reject reason code. If you cannot see that share by country, scheme and reason, customers will tell you first.

I export four metric families and one dashboard sits on top of them:

@Component
public class RejectionMetrics {

    private final MeterRegistry registry;

    public RejectionMetrics(MeterRegistry registry) { this.registry = registry; }

    public void outboundBlocked(String messageType, String code) {
        registry.counter("payments.outbound.blocked",
                "msg", messageType, "code", code).increment();
    }

    public void networkRejected(String scheme, String reasonCode, String country) {
        registry.counter("payments.network.rejected",
                "scheme", scheme, "reason", reasonCode, "country", country).increment();
    }

    public Timer repairLatency() {
        return Timer.builder("address.repair.latency")
                .publishPercentileHistogram().register(registry);
    }
}
Enter fullscreen mode Exit fullscreen mode

The dashboard has to answer four questions:

  • What fraction of payments is parked for repair each day?
  • Which reason codes dominate? TOWN_MISSING points at bad legacy data; a growing STREET_UNSPLIT suggests missing parser rules.
  • What is the 95th percentile repair time? That is your cash-flow exposure.
  • How many messages passed my gate and were still rejected by the network? Anything above zero means my rules disagree with the scheme.

Keep country as a label but watch cardinality: a few dozen values are fine, free text never is. To follow a parked payment across services, the tracing setup in distributed tracing with OpenTelemetry in Spring Boot lets you correlate everything with one trace id.

First days after the cutover

If your platform is live or nearly live, this is the order I would work in:

  1. Use the hybrid format everywhere first. It needs only TwnNm and Ctry as real elements, and it is the cheapest route for messages with no structured source data.
  2. Make the gate fail closed for outbound messages with an explicit violation code, instead of finding out through downstream rejections.
  3. Back-fill country codes. Turning "Deutschland" into DE was the best-value data fix I made.
  4. Staff the repair queue. A queue nobody works is just a slower way to lose payments.
  5. Replay recent real traffic through the validator and count would-be rejections by reason. That count shows how far you are from compliant.

Deterministic replay of recorded traffic is a habit from my market-data work; the same idea carries over to payment messages, and I wrote the technique up in the replay and backtesting article.

Beyond addresses: VoP and the 2027 dates

Addresses are not the only euro-area change. The EU Instant Payments Regulation requires Verification of Payee (VoP): before a transfer is confirmed, the bank checks the entered name against the name on the receiving account and returns "match", "close match" or "no match". The EPC's VoP scheme rulebook defines the message exchange. The dates I worked to were October 2025 for payment service providers in the euro area and July 2027 for providers in EU member states outside it. Confirm them against the regulation and your national regulator, since that is my reading and not legal advice.

The link to this post is practical. Name matching works better when counterparty data is clean, so a pipeline that already produces normalised structured data gives you a head start. Treat the 2027 dates as a schedule for extending the same layer, not as a separate project. The same thinking applies to identity data; my EUDI wallet verifier build shows how verified attributes can feed a payment flow. For recurring mandates, where debtor and creditor details have to stay consistent over time, see Commercial VRP in Spring Boot.

In Singapore and elsewhere in Asia, local ISO 20022 migrations hit the same data problem, and the cross-border part follows the same CBPR+ guideline. A closely related routing problem is in my ASEAN QR payments post.

What I would tell my earlier self

This migration is a data quality project dressed up as an XML change. Teams that struggled spent their effort on schema validators while the address column kept supplying garbage. Teams that coped began with the data, measured it, and put a boundary in front of it that made uncertainty visible.

Three rules held up through all of the above:

  • Never guess silently. Every parse carries a confidence score and reasons.
  • Never lose a payment. The outbox ties state and events together in one transaction.
  • Never fly blind. Metrics by reason, country and scheme make a cutover observable.

One concrete step before you close this tab: export a day of real outbound payments and run them through a validator. The count of failures is your actual starting point.

FAQ

What is a structured postal address in ISO 20022?

It is an address split into separate labelled fields such as street name, building number, postcode, town and country, instead of one block of free text. Payment systems can then read each part reliably.

What changes on 15 November 2026?

Payment messages that carry only free-text address lines are no longer accepted on the Swift cross-border network and in SEPA schemes. You must send either fully structured fields or a hybrid form that has at least town and country as fields.

What is a hybrid address?

A hybrid address mixes the two styles. Town and country are filled in as proper fields, and up to two free-text lines can still hold the street and building details.

Why will payments be rejected even if the XML is correct?

Because the source data is often a single free-text customer address stored years ago. If it cannot be split reliably, the message has nothing valid to put in the structured fields.

Related Articles

Top comments (0)