sandbox.bankconnector.com API Reference

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"
}
  • severity is error (blocks) or warning (informational: the payment still proceeds). It's omitted when it would be error (the default); check valid / the absence of error-severity issues to know if a payment can proceed.
  • level tells you which stage raised it: generic, bank, or payment-type.

Validation runs in four levels, and stops at the first structural failure:

  1. Structural (schema): if the JSON doesn't match the canonical schema, you get only GENERIC_SCHEMA_* issues and nothing else runs. On BankConnector-Version: 2026-08-25 this returns 422, 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 a 400. ⚠️ On 2026-06-01 (and with no version header) a schema failure is a 400 instead; see the changelog.
  2. Generic rules: currency, amount, dates, references (below).
  3. Bank rules: per-bank requirements (BIC required, national account checksums, etc.).
  4. Payment-type rules: per scheme (SEPA EUR-only, KID checksum, etc.).

Generic rules (apply to every payment)

CodeSeverityMeaning
GENERIC_SCHEMA_*errorJSON failed the canonical schema (per-field codes, e.g. GENERIC_SCHEMA_AMOUNT_REQUIRED)
GENERIC_AMOUNT_POSITIVEerroramount ≤ 0 (so "0.00" fails here)
GENERIC_AMOUNT_PRECISIONerrora 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_INVALIDerrornot an active ISO 4217 code
GENERIC_COUNTRY_CODE_INVALIDerrordebtor country not ISO 3166
GENERIC_PAYMENT_ID_DUPLICATEerrorpaymentId reused across payments
GENERIC_END_TO_END_ID_DUPLICATEerrorendToEndId reused in the batch
GENERIC_INSTRUCTION_ID_DUPLICATEerrorinstructionId 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_INVALIDerrorRF reference fails ISO 11649 mod-97
GENERIC_CREDITOR_REFERENCE_KID_INVALIDerrorKID fails MOD10/MOD11
GENERIC_PURPOSE_CODE_NOT_PLACEABLEerrora 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_MISMATCHwarningBIC country ≠ IBAN country
GENERIC_EXECUTION_DATE_PASTwarningdate is in the past
GENERIC_EXECUTION_DATE_FAR_FUTUREwarningmore than a year ahead
GENERIC_EXECUTION_DATE_NON_BANKINGwarningweekend/holiday; suggests next banking day
GENERIC_EXECUTION_DATE_INTL_TOO_SOONwarninga 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_BATCHwarningmore 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)

CodeSeverityMeaning
PROFILE_NAME_TRUNCATEDwarninga name was shortened to the bank's limit
PROFILE_REMITTANCE_TRUNCATEDwarningremittance text was shortened
PROFILE_CHARSET_TRANSLITERATEDwarningcharacters were transliterated to the SEPA set
PROFILE_NAME_CHARS_DROPPEDwarninga 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_INVALIDwarningan 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_UNREPRESENTABLEerrora 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: an initiatingParty.organisationId is 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 a purposeCode, which the engine reports as RgltryRptg/Dtls/Cd, but states no creditor.country (and no creditor IBAN to take one from). The sibling Ctry holds the beneficiary's residence country, so it is left out rather than guessed — and the UAE high-value rail requires it. Set creditor.country, or supply regulatoryReporting yourself.

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)

TypeKey requirements
sepacreditor IBAN required; EUR only
sepa-instantas SEPA + amount ≤ €100,000
internationalcreditor 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:

CallSuccess responseWhere the warnings are
POST /api/validate/{bank}200 JSONissues, mixed with any errors — filter on severity
POST /api/journal/payments201 JSONwarnings (top level, omitted when there are none)
POST /api/journal/payments/preview200 JSONwarnings
POST /api/convert-async/{bank}202 JSONwarnings
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 call422 JSONerror.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:

  1. Build time — the developer who makes the mistake. test/banks/payment-type-catalog-guard.test.ts fails the build if ANY registered bank offers zero rails (a country with no COUNTRY_PAYMENT_TYPES entry, a paymentTypes narrowing that intersects emptily, or an excludePaymentTypes — possibly inherited via deriveBank — 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.
  2. Setup time — the operator who will live with it. createConnection checks the connection's whole covered bank set (a group-shared Danske DK/SE/NO connection carries one bank_key and serves several, and the refusal is per-bank) and, if any of them offers no rails, journals and emits connection.no-payment-rails — once, at creation. The payload names BANK_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#readinessFor gates 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 (pollInboundmodules/connectivity/ingest.ts, banking/inbound/parse-camt053.ts, parse-mt940.ts, parse-pain002.ts) reference neither offeredPaymentTypes nor COUNTRY_PAYMENT_TYPES at all.
  • A verified inbound is by itself enough to call a production connection LIVE. production_verified_at is 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.