NetSuite · Treasury · 16 July 2026

An ISO 20022 pain.001 file the bank accepts first time

Every international wire was being re-keyed by hand in the bank portal because the payment file kept failing validation. Here is what was actually wrong — and the one trap that overpays vendors instead of erroring.

The situation

International vendor payments were leaving NetSuite as a file the bank rejected. The fallback was manual: open the bank portal, type in the beneficiary, the account, the amount, the address, and send. Every wire, every week, with the error rate that implies — and an approval trail that lived in someone's memory rather than in the ERP.

The goal was simple. Approve the payment in NetSuite, generate a file, upload it, done.

Decision one: generic XML, not the bank's CSV

The bank's onboarding pack specified a CSV layout. Their own documentation, their own field order, their own delimiters. The obvious move is to build what they asked for.

We built the ISO 20022 XML instead, using NetSuite Electronic Bank Payments' generic XML output rather than one of the packaged formats.

The reasoning: pain.001 is a published international standard with a schema you can validate against before you ever upload anything. A bank-specific CSV is a private agreement documented in a PDF, with the failure mode of a rejection notice two days after you thought the vendor was paid. When a bank supports both — and most now do, because ISO 20022 migration is not optional for them — the standard is the safer target even when the proprietary format looks simpler.

It also travels. A CSV built for one bank is worthless at the next one. The pain.001 is ninety percent portable.

What actually fails validation

1. Entities arrive already escaped, then get escaped again

This is the one that cost the most time and is invisible when you read the output casually.

Text coming out of NetSuite into a FreeMarker template can already carry HTML entities — a vendor named Smith & Sons arrives as Smith & Sons. Apply ?xml to that, as you must for valid XML, and the ampersand escapes a second time. The bank's parser sees a malformed entity and rejects the file.

The fix is to un-escape first, then let ?xml do its job exactly once:

<#function unesc s>
  <#return s?replace("&lt;","<")?replace("&gt;",">")
            ?replace("&quot;","\\"")?replace("&apos;","'")
            ?replace("&#39;","'")?replace("&amp;","&")>
</#function>

And here is the trap inside the trap. That function only works if the entity strings survive being pasted into the NetSuite template field. Paste the template in and the field can entity-decode it — so &amp; in your source becomes a bare & in the saved template, and every replacement becomes a no-op. The template still looks right. It silently stops working.

Post-paste check: reopen the saved template and confirm the unesc() line still shows entity text inside the quotes. Bare & < > " characters mean the paste got decoded and the fix is dead. Do this every single time you edit the template, not just the first time.

2. Characters the payment network won't carry

SWIFT-based messaging accepts a restricted character set. Punctuation that is ordinary in an address field — slashes, colons, pipes, asterisks, plus signs — will fail validation or get mangled downstream. Leading ampersands and hyphens cause their own problems.

<#function clean s>
  <#return s?replace("[<>:|~+=;*/]", " ", "r")
            ?replace("^[&-]+", "", "r")?trim>
</#function>

Apply it to everything free-text that a human typed: vendor name, bank name, every address line. Not to account numbers, IBANs, or BICs — those are already constrained, and stripping characters there would corrupt them.

3. Field lengths are hard limits, not guidance

ISO 20022 caps most name and identifier fields at 35 characters and unstructured remittance at 140. Exceed them and the file is rejected outright — there is no truncation courtesy.

<Nm>${vendorName[0..*35]?xml}</Nm>
<EndToEndId>${e2e[0..*35]?xml}</EndToEndId>
<Ustrd>${memoVal[0..*140]?xml}</Ustrd>

FreeMarker's [0..*n] is the safe slice — it won't throw when the string is shorter than the limit, which [0..n] will.

4. Empty is not the same as absent

An element that exists but is empty fails validation. An element that isn't there is often fine. So every optional block gets a content guard:

<#if ctcNm?has_content || ctcPh?has_content>
  <CtctDtls>
    <#if ctcNm?has_content><Nm>${ctcNm?xml}</Nm></#if>
    <#if ctcPh?has_content><PhneNb>${ctcPh?xml}</PhneNb></#if>
  </CtctDtls>
</#if>

The inverse also applies. EndToEndId is mandatory, so when there's no memo to put in it you emit the literal NOTPROVIDED rather than an empty string — that's the value the standard defines for exactly this case.

5. Bank identification the ERP doesn't populate for you

Two gaps worth knowing about.

The debtor agent — your own bank — needs its clearing system member ID in the file, and it isn't sourced automatically. It has to be written in explicitly:

<DbtrAgt><FinInstnId><ClrSysMmbId>
  <ClrSysId><Cd>USABA</Cd></ClrSysId>
  <MmbId>YOUR_ROUTING_NUMBER</MmbId>
</ClrSysMmbId></FinInstnId></DbtrAgt>

The same gap appeared on the ACH side of the project, where the routing information wasn't being picked up either and had to be added as an explicit condition.

On the beneficiary side, some vendor records carry a BIC and others a SWIFT code in a different field, and some have an IBAN while others have a plain account number. Both need a fallback rather than an assumption:

<#assign bankId = (bicVal?has_content)?then(bicVal, swiftVal)>

<CdtrAcct><Id>
  <#if ibanVal?has_content>
    <IBAN>${ibanVal}</IBAN>
  <#else>
    <Othr><Id>${acctVal}</Id></Othr>
  </#if>
</Id></CdtrAcct>

6. Phone numbers have a format the bank enforces

Ours wanted +CC-number — country code, mandatory hyphen, no spaces. +39-03968781. Nothing in NetSuite enforces that on a vendor bank detail record, so it becomes a data-entry standard you document and check, or a normalisation step in the template.

The trap that doesn't error: currency

This is the one to take away even if you never build a pain.001.

The instructed amount carries a currency label:

<InstdAmt Ccy="USD">${formatAmount(payment.amount,"dec")}</InstdAmt>

Electronic Bank Payments hands you payment.amount already converted into the funding account's currency. Pay a EUR-billed vendor from a USD account and you get the USD figure — a EUR 11,590.50 invoice arrives in the template as 13,218.85 at the day's rate. Labelling that USD is correct, and the pairing is consistent.

Now suppose a vendor later insists on receiving exact EUR. The intuitive fix is to change the label to EUR. That produces a perfectly valid file which the bank will accept without complaint — and it sends 13,218.85 euros against an 11,590.50 euro invoice.

The amount source has to change with the label, or the vendor is overpaid by the FX rate. There is no validation error, no rejection, no warning. Just a wire that's about fourteen percent too big, discovered whenever someone reconciles.

If you hardcode a currency — and hardcoding it is a legitimate policy decision, not a shortcut — leave a comment in the template saying exactly this. The person who changes it in two years will not be you.

There is no validation error and no rejection. Just a wire that is fourteen percent too big, discovered whenever someone reconciles.

The other decision worth documenting: who pays the fees

<ChrgBr>SHAR</ChrgBr>

SHAR splits the charges, OUR puts them all on you, BEN puts them all on the vendor. It is a single element and it is a commercial term, not a technical one — the difference shows up as vendors querying short payments. Whichever you pick, it should match what's in the vendor agreement, and it's worth confirming per-vendor rather than assuming one setting fits everyone.

The reason that outlasts the format

Wires had the same disease as the ACH runs, and it's worth stating plainly because it's the real return on this work.

When the payment is assembled in the bank portal and recorded in the ERP separately, the two can drift. The wire goes out and nobody marks the bill paid, so it sits open and gets paid again. Or it's marked paid and the wire never actually sent, so a vendor waits while your books say they were settled.

Generating the file from the approved payment record collapses that into one operation. The file cannot exist without the payment record, and the payment record is what the file was built from. No gap, no second step, no reliance on somebody remembering at the end of a long day.

The format work above is what makes it possible. This is why it's worth doing.

What changed

Wires go out straight from the ERP. No re-keying in the bank portal, no transcription errors, and the approval trail lives on the payment record where an auditor can find it.

The broader point: the bank asked for a CSV and building the standard instead was more work up front and less work permanently. When a vendor hands you a proprietary format and a published standard is available, the standard is usually the cheaper answer measured across the life of the integration.

Related: when the error you're chasing is only a symptom.

New England Systems Group

If there's a process everyone works around instead of through, that's where we start.

SuiteScript development and back-office automation for manufacturers and distributors. Danbury, Connecticut.

hello@nesystemsgroup.com