Validation Reference
Audience: 🔌 Integration Client
When a payment is rejected 422, the reasons come back as a list of issues in error.details.issues. 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. Both a malformed body and a schema-invalid canonical payment return400here; a well-formed payment that fails a semantic rule (levels 2–4 below) returns422. - 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).
Query GET /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) | 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.
Handling issues in your client
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`.
}
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.