Validation Reference
Audience: 🔌 Integration Client
When a payment is rejected, the reasons come back as a list of issues in error.details.issues — under 422 and under 400 alike, so one parse handles both. Each issue looks like:
{
"code": "PAYMENT_TYPE_SEPA_EUR_ONLY",
"level": "payment-type",
"severity": "error",
"message": "SEPA payments must be in EUR.",
"path": "payments[0].transactions[0].currency"
}
severityiserror(blocks) orwarning(informational: the payment still proceeds). It's omitted when it would beerror(the default); checkvalid/ the absence oferror-severity issues to know if a payment can proceed.leveltells you which stage raised it:generic,bank, orpayment-type.
Validation runs in four levels, and stops at the first structural failure:
- Structural (schema): if the JSON doesn't match the canonical schema, you get only
GENERIC_SCHEMA_*issues and nothing else runs. OnBankConnector-Version: 2026-08-25this returns422, the same as a payment that fails a semantic rule (levels 2–4 below) — both mean "we read your request and cannot process this payment". A body that is not JSON at all is a400. ⚠️ On2026-06-01(and with no version header) a schema failure is a400instead; see the changelog. - Generic rules: currency, amount, dates, references (below).
- Bank rules: per-bank requirements (BIC required, national account checksums, etc.).
- Payment-type rules: per scheme (SEPA EUR-only, KID checksum, etc.).
Generic rules (apply to every payment)
| Code | Severity | Meaning |
|---|---|---|
GENERIC_SCHEMA_* | error | JSON failed the canonical schema (per-field codes, e.g. GENERIC_SCHEMA_AMOUNT_REQUIRED) |
GENERIC_AMOUNT_POSITIVE | error | amount ≤ 0 (so "0.00" fails here) |
GENERIC_AMOUNT_PRECISION | error | a non-zero sub-unit the currency can't represent (e.g. "1000.50" JPY). Trailing zeros ("1000.00") are fine; the engine converts precision for you |
GENERIC_CURRENCY_CODE_INVALID | error | not an active ISO 4217 code |
GENERIC_COUNTRY_CODE_INVALID | error | debtor country not ISO 3166 |
GENERIC_PAYMENT_ID_DUPLICATE | error | paymentId reused across payments |
GENERIC_END_TO_END_ID_DUPLICATE | error | endToEndId reused in the batch |
GENERIC_INSTRUCTION_ID_DUPLICATE | error | instructionId reused in the batch. InstrId is a point-to-point de-duplication reference, so a repeat can silently drop a real payment |
GENERIC_CREDITOR_REFERENCE_RF_INVALID | error | RF reference fails ISO 11649 mod-97 |
GENERIC_CREDITOR_REFERENCE_KID_INVALID | error | KID fails MOD10/MOD11 |
GENERIC_PURPOSE_CODE_NOT_PLACEABLE | error | a purposeCode override longer than the ISO Purp/Cd limit (4 chars) with no corridor rule to place it. Use a 4-char ISO code, or supply regulatoryReporting explicitly |
GENERIC_BIC_IBAN_COUNTRY_MISMATCH | warning | BIC country ≠ IBAN country |
GENERIC_EXECUTION_DATE_PAST | warning | date is in the past |
GENERIC_EXECUTION_DATE_FAR_FUTURE | warning | more than a year ahead |
GENERIC_EXECUTION_DATE_NON_BANKING | warning | weekend/holiday; suggests next banking day |
GENERIC_EXECUTION_DATE_INTL_TOO_SOON | warning | a cross-border payment to a country whose banking calendar isn't modelled, with little lead time. International settlement usually needs a few business days |
GENERIC_MIXED_CURRENCY_BATCH | warning | more than one currency in one payment |
The duplicate-id errors matter: banks de-duplicate on paymentId, endToEndId and instructionId and may silently drop repeats, so BankConnector blocks them up front. Keep them unique within a batch.
Profile warnings (character set / truncation)
| Code | Severity | Meaning |
|---|---|---|
PROFILE_NAME_TRUNCATED | warning | a name was shortened to the bank's limit |
PROFILE_REMITTANCE_TRUNCATED | warning | remittance text was shortened |
PROFILE_CHARSET_TRANSLITERATED | warning | characters were transliterated to the SEPA set |
PROFILE_NAME_CHARS_DROPPED | warning | a mixed-script name survives transliteration but loses characters that have no Latin rendering ("王伟 Ltd" → "Ltd"). The bank receives a materially different name (Confirmation-of-Payee mismatch risk). Supply the Latin form of the full name |
PROFILE_ID_CHARSET_INVALID | warning | an identifier (messageId, paymentId, instructionId, endToEndId) has characters outside the SEPA set. Unlike names, ids are sent verbatim (to keep pain.002/camt correlation), so the bank may reject the batch or transcode the id and break correlation. Use only A–Z a–z 0–9 and / - ? : ( ) . , ' + and space |
PROFILE_NAME_UNREPRESENTABLE | error | a required name transliterates to empty (e.g. CJK-only) |
Remember the order: transliterate, then truncate. Transliteration can _expand_ text (ä→ae, ß→ss), so a name that looks short can still truncate.
Note the split between the two "lossy name" codes: PROFILE_CHARSET_TRANSLITERATED fires on any change (é→e counts); PROFILE_NAME_CHARS_DROPPED fires only when characters are removed outright, which is the one that actually changes who the bank thinks is being paid.
Bank-rule examples
Different banks require different things. Common ones:
BANK_BIC_REQUIRED: this bank requires the debtor BIC.BANK_BIC_VALUE_INVALID: the debtor BIC doesn't match the expected institution.BANK_INITIATING_PARTY_ID_REQUIRED: aninitiatingParty.organisationIdis required.BANK_DEBTOR_COUNTRY_REQUIRED/BANK_DEBTOR_ACCOUNT_CURRENCY_REQUIRED.BANK_BBAN_FORMAT_INVALID/BANK_BBAN_CHECKSUM_INVALID: national account number checks (Norwegian MOD-11, Danish structure, Polish NRB mod-97).BANK_AE_REGULATORY_COUNTRY_UNKNOWN(warning): a UAE payment carries apurposeCode, which the engine reports asRgltryRptg/Dtls/Cd, but states nocreditor.country(and no creditor IBAN to take one from). The siblingCtryholds the beneficiary's residence country, so it is left out rather than guessed — and the UAE high-value rail requires it. Setcreditor.country, or supplyregulatoryReportingyourself.
Query GET /api/banks/<bank>/payment-types and the bank's setup info to see what a given bank expects before you build against it.
Payment-type rules (by scheme)
| Type | Key requirements |
|---|---|
sepa | creditor IBAN required; EUR only |
sepa-instant | as SEPA + amount ≤ €100,000 |
international | creditor account and creditor BIC required |
fik (DK) | valid FIK reference (kortart 71 + Luhn) |
kid (NO) | 2–25 digits, MOD10/MOD11 |
bankgiro / plusgiro (SE) | Luhn-checked, digit-length bounded |
bacs / chaps / faster-payment (UK) | 6-digit sort code + 8-digit account; GBP only; Faster ≤ £1,000,000 |
ach / wire (US) | 9-digit routing + ABA checksum; USD only |
ch-domestic / ch-qr (CH) | CH/LI creditor IBAN + CHF/EUR; QR adds a 27-digit reference check |
eft (CA) | routing + account; CAD only |
If you omit paymentType, the engine attempts to infer it and returns autoSelected in the response. If it can't (PAYMENT_TYPE_AUTO_UNRESOLVED) or the type isn't offered by that bank (PAYMENT_TYPE_NOT_OFFERED), you get an error; set it explicitly when in doubt.
A third, rarer error means the fault is ours, not yours: BANK_OFFERS_NO_PAYMENT_TYPES says the bank profile has no payment types configured at all, so no rail can be selected and no file is produced. Setting paymentType explicitly will not help — tell us, and we fix the profile.
Where warnings are returned
A warning never blocks anything. It reports a file the bank will _accept_ while doing something other than what you asked — a payment marked urgent riding a non-urgent rail (it settles at normal speed), a creditor name the bank will truncate. So warnings arrive on success responses, not only on a 422:
| Call | Success response | Where the warnings are |
|---|---|---|
POST /api/validate/{bank} | 200 JSON | issues, mixed with any errors — filter on severity |
POST /api/journal/payments | 201 JSON | warnings (top level, omitted when there are none) |
POST /api/journal/payments/preview | 200 JSON | warnings |
POST /api/convert-async/{bank} | 202 JSON | warnings |
POST /api/convert/{bank} | 200 XML (the file) | X-Validation-Warnings header — the body is a file, so there is no JSON field to put them in |
| any call | 422 JSON | error.details.issues, mixed with the errors that caused the rejection |
warnings holds exactly the same issue objects as issues — same code, message, path, level — filtered to severity: "warning". One shape on every path, so one parser handles them all.
Date rules are evaluated in the tenant's timezone, not UTC. GENERIC_EXECUTION_DATE_PAST, GENERIC_EXECUTION_DATE_FAR_FUTURE and GENERIC_EXECUTION_DATE_INTL_TOO_SOON all derive "today" from a timezone, so what counts as "past" depends on which calendar day the check runs in. On POST /api/convert/{bank} and POST /api/convert-async/{bank} that is the timezone of the company your credential names (a signed-in session or an install's own organisation; an API key that supplies ?platformId=&companyId= picks one per request); a credential that names no single company — a platform-scoped API key that omits those params, or an unauthenticated/dev-mode call — is evaluated in UTC.
The header on POST /api/convert/{bank} is the one exception, and it carries codes only:
X-Validation-Warnings: SEPA_INSTRPRTY_IGNORED=1,PROFILE_NAME_TRUNCATED=3
CODE=count pairs, comma separated, one per distinct code — so the header is bounded by the number of warning _codes_, not by the size of your batch. code is the stable value you branch on anyway; for the full messages and paths, post the same body to POST /api/validate/{bank}.
All of these fields are additive: they appear only when a payment actually raised a warning, so a response for a clean payment is unchanged.
Handling issues in your client
// Rejected (422): the issues that caused it, plus any warnings alongside them.
if (res.status === 422) {
const { error } = await res.json();
const blocking = (error.details?.issues ?? []).filter((i: any) => i.severity !== "warning");
const warnings = (error.details?.issues ?? []).filter((i: any) => i.severity === "warning");
// Surface `blocking` to whoever owns the payment data; log `warnings`.
}
// Accepted (201): the payment went through — log anything it warned about.
if (res.status === 201) {
const { journalNo, warnings = [] } = await res.json();
for (const w of warnings) console.warn(`${journalNo} ${w.code} @ ${w.path}: ${w.message}`);
}
Treat errors as "fix and resubmit," and warnings as "worth logging, but it went through." Don't discard warnings: a truncation or non-banking-day warning is often the first sign of a data-quality issue upstream.
A bank with no configured payment rails REFUSES the payment
A bank profile's payment rails come from its country. A profile whose country has no entry — or whose narrowing intersects to nothing — offers zero rails, and the engine used to carve that case out: auto selection returned nothing, the payment's type was deleted, every payment-type rule was skipped, and the emitted file carried no payment-type-information block at all. No error, no warning. The payment went to the bank with no rail code and the customer had no way to know.
A bank profile that offers no payment types is a CONFIGURATION error, and a payment routed to it is refused at conversion with a blocking issue — BANK_OFFERS_NO_PAYMENT_TYPES — and no file is produced.
Two alternatives were declined:
- Keep it. An untyped file is valid ISO 20022 and some banks infer the rail themselves. Rejected: a zero-rail profile is not a bank we can send to, it is a profile nobody finished configuring, and the failure lands on the customer at the bank rather than on us at conversion.
- Warn and proceed. Rejected: this is the money path, and a warning on a file that has already left is a record, not a control.
A third — also detect it at setup time — was deferred with the original decision and has since been taken; it is the section below.
On the name: the obvious sibling of the existing "type not offered" code was rejected for being exactly that — two machine-branchable codes differing by three characters, on the money path, is a bug waiting to be written. The chosen name also says where the fault is: the bank profile, not the payment.
Scope boundaries are deliberate: the existing codes keep their jobs — "rails ARE offered, none matched" and "this payment named a type this bank does not offer" — and the new one covers only the case that used to pass silently. Zero of the registered banks offer zero rails today, so this was a latent defect rather than a live one, which is what made the strict answer cheap now and expensive later; its guard runs against a synthetic test-only bank, because that is the only honest way to reach the branch.
…and it is now SAID at setup time — as a notice, never a refusal
The refusal above is correct and it is still the enforcement point, but on its own it lands on the first payment: the money is already moving in the customer's ERP, and the only person who can fix a bank profile — the operator who set the connection up — was last present days earlier. So the same fact is now detected and surfaced where it can still be acted on. There are two detectors, at two different times, and neither is the refusal:
- Build time — the developer who makes the mistake.
test/banks/payment-type-catalog-guard.test.tsfails the build if ANY registered bank offers zero rails (a country with noCOUNTRY_PAYMENT_TYPESentry, apaymentTypesnarrowing that intersects emptily, or anexcludePaymentTypes— possibly inherited viaderiveBank— that removes the last one). Zero of the registered banks are zero-rail, so this is a ratchet. It is a _floor_, not a substitute for the two runtime checks: it can only see the compiled catalog. - Setup time — the operator who will live with it.
createConnectionchecks the connection's whole covered bank set (a group-shared Danske DK/SE/NO connection carries onebank_keyand serves several, and the refusal is per-bank) and, if any of them offers no rails, journals and emitsconnection.no-payment-rails— once, at creation. The payload namesBANK_OFFERS_NO_PAYMENT_TYPES, so the setup notice and the refusal a customer might later hit join on one string.
⚠️ Setup-time detection does NOT refuse the connection, and must not become a refusal. The obvious version of this change — make createConnection throw on zero rails — assumes every connection exists in order to send payments. It does not. A connection can legitimately be inbound-only: statements and camt/pain.002 status reports, and never an outbound rail. That is not a theoretical shape, it is what this codebase actually supports, on three independent measurements:
- Activation never consults the rails.
modules/connectivity/readiness.ts#readinessForgates go-live on the channel's own prerequisites (SFTP: PGP/SSH/server; Web Services: config + certs; EBICS: INI/HIA/HPB) plus one approver. No payment type appears anywhere in it, so a zero-rail connection activates normally. - The inbound path never consults them either. Polling, fetching, parsing and settling (
pollInbound→modules/connectivity/ingest.ts,banking/inbound/parse-camt053.ts,parse-mt940.ts,parse-pain002.ts) reference neitherofferedPaymentTypesnorCOUNTRY_PAYMENT_TYPESat all. - A verified inbound is by itself enough to call a production connection LIVE.
production_verified_atis stamped on the first successful production delivery or the first verified production inbound (modules/connectivity/pg-store.ts,infrastructure/db/migrations/0016_connection_production_verified_at.sql) — so the product already recognises a connection that has proven itself without ever sending a payment.
Refusing on rails alone would therefore break a supported setup, at the moment an operator is least able to diagnose it, with a message about payments they never intended to send. The notice is worded accordingly: it says what will not work (sending), names the code, and says plainly that an inbound-only connection needs no action.
Fired once, at creation, and deliberately not repeated at activate() — an inbound-only operator would otherwise be nagged every time they touched a correctly-configured connection, and a notice that fires on a correct configuration is one people learn to ignore.
All three checks share the one predicate — offeredPaymentTypes(...).length === 0 — rather than restating it, so build time, setup time and conversion time cannot drift apart.