Errors & retries
The error envelope
Almost every error is:
{ "error": { "code": "validation_failed", "message": "…", "details": { "issues": [ … ] } } }
codeis a stable, snake_case string. Branch oncode, never onmessage(messages can change and are never localised).detailsis present only when there's structured extra info (e.g. validationissues).
✅ Every non-2xx response from the API uses this envelope, including
413(payload_too_large) and the idempotency terminal error (internal_error), so you can rely onbody.error.code. The API Reference'sErrorResponseschema matches.
413 payload_too_large tells you how to split
The request-body cap is 8 MB on the routes you deliver a raw bank file to (POST /api/journal/ingest, POST /api/inbound/statement, POST /api/inbound/payment-status), because a statement is one file and there is nothing to split. Every other route, the payment routes included, is capped at 1 MB. The 413 always names the cap you hit, in details.limitBytes.
On the payment routes the cap is on bytes, not on how many payments or transactions you may send, and it shadows the 10,000-transaction schema cap, which you therefore cannot reach over HTTP. The effective transaction limit is 1 MB divided by the size of your transactions, so the 413 computes it from the refused request and puts the figures in details:
| field | when | meaning |
|---|---|---|
limitBytes | always | the cap, in bytes |
observedBytes | always | your body size, or the bytes read before the cap fired |
observedBytesExact | always | true when observedBytes is your whole body |
suggestedRequests | you sent a Content-Length | split into at least this many requests |
observedTransactions | no Content-Length | transactions counted in what we read |
suggestedTransactionsPerRequest | no Content-Length | send at most this many transactions per request |
Give each part its own unique messageId, because duplicate control keys on it.
The 400 vs 422 boundary
Send BankConnector-Version: 2026-10-21 on every call. A call without it behaves as 2026-06-01, where a payment that fails the canonical schema is 400, not the 422 described here. The line moved for payments on 2026-08-25 and for every other request body on 2026-10-20; see below if you are pinned to an older version.
400 validation_failedmeans we could not read your request: it was not JSON, a header was malformed, or a path parameter was missing.422 validation_failedmeans the request was fine and what is inside it is not something we can process. For a payment that covers both a payment that does not match the canonical schema and one that matches it but breaks a rule. For any other body it covers a field of the wrong type or value, and a field the route does not read. The specific problems are inerror.details.issueseither way: a payment's carry acode, every other body's apathand amessage.
So: 400 → fix your request construction; 422 → surface the issues to whoever supplied the payment data. Neither is retryable as-is.
⚠️ On
BankConnector-Version: 2026-06-01(and on requests that send no version header), a payment that fails the canonical schema is400, not422. Only a rule failure is422there. Pin2026-08-25for the boundary described above. The changelog has the per-route table.⚠️ Before
2026-10-20, any other body that fails its route's schema is400, and a field the route does not read is dropped without a word. The issues are inerror.details.issueson every version. See the changelog.
409 invalid_state: not ready for this yet
From 2026-10-21, a request that is fine in itself but names something not at a point that allows it is 409 invalid_state: a bank connection whose earlier setup step is not done, that is live when the action needs it deactivated, or that runs on a channel the action does not apply to. The message names what to do first. Do that, then send the request again; sending it unchanged gets the same answer. On earlier versions these refusals are validation_failed (400, and 422 on 2026-10-20). See the changelog.
What's retryable
| Status | code | Retry? | How |
|---|---|---|---|
429 | rate_limited | ✅ yes | wait for Retry-After / retryAfterSeconds, then retry |
503 | overloaded | ✅ yes | transient; honour Retry-After (~2 s) |
503 | service_unavailable | ✅ yes | a dependency is down; back off |
409 | request_in_progress | ✅ yes | the same request is still running; send it again unchanged, shortly |
409 | conflict | ❌ no | you reused a messageId or key with a changed body; fix it |
409 | message_id_already_used | ❌ no | that messageId's payment was rejected (by the bank or an approver) or cancelled; resubmit with a new one |
409 | invalid_state | ❌ no | do the step the message names first, then send the request again |
500 | internal_error | ⚠️ maybe | retry once with backoff; if it persists, capture X-Request-ID and escalate |
500 | internal_error (unreadable stored response) | ❌ no | the message says the stored response is unreadable; it stays so for 90 days. Submit again with a new messageId |
502 | bank_unavailable | ✅ yes | the bank is not reachable; retry later with backoff |
502 | bank_outcome_unknown | ❌ no | never retry blind: read the payment's status again first |
502 | sftp_probe_failed etc. | ⚠️ maybe | upstream hiccup; limited retry |
400 | unsupported_pain_version | ❌ no | the bank does not take that pain.001 version; change the version or the bank |
402 | billing_past_due | ❌ no | an invoice has been unpaid for over 30 days; new production payments are refused until it is paid |
402 | trial_payments_exhausted | ❌ no | the ten free production payments are used and there is no package; subscribe, then send again |
422 | sends_paused | ❌ no | new payments are paused for your organisation; contact support |
400 | any | ❌ no | fix the request |
400 | bank_rejected | ❌ no | the bank refused a setup step; its own reply is in error.details (bankText, returnCode, nextSteps) |
422 | mandatory_identifier_missing | ❌ no | save the identifiers error.details.identifiers names, then activate again |
403 | capability_disabled | ❌ no | the feature (e.g. webhooks) is not enabled for your organisation |
404 | raw_file_expired | ❌ no | the raw statement file passed its retention window; the statement JSON stays readable |
401 | unauthorized | ❌ no* | re-authenticate (check the key); not a blind retry |
403 | forbidden / admin_required | ❌ no | scope/role problem; will never succeed as-is |
404 | *_not_found | ❌ no | wrong id / resource |
422 | validation_failed etc. | ❌ no | fix the data |
Rule of thumb: retry 429, 503, 502 bank_unavailable, 409 request_in_progress, and (cautiously) other 5xx. Never retry 502 bank_outcome_unknown or the unreadable-response 500 as they are, and never blind-retry a 4xx other than 409 request_in_progress. A retry resends the same body with the same Idempotency-Key and messageId, so it can never create a second payment.
The three faces of 409
On POST /api/journal/payments, 409 means one of three things:
request_in_progress: the same request is still being processed → retry the same request shortly, orconflict: the samemessageIdorIdempotency-Keyarrived with a different payload → do not retry, ormessage_id_already_used: the payment you first sent under thismessageIdwas rejected (by the bank or by one of your approvers) or cancelled, and this re-send was not attempted → do not retry; correct the payment and submit it with a newmessageId. A newIdempotency-Keydoes not help, because themessageIdis the payment's identity.
Each has its own code, so branch on code alone. (Details in Idempotency.)
Rate limiting
There are two limits, and they stack. Both are on by default on any deployed server, sandbox included.
1. The per-caller limit (300 / 60 s)
The limit is 300 requests per 60 seconds per caller. It applies to a specific list of expensive routes: mostly POST, plus five GET journal-read routes.
POST /api/journal/payments,POST /api/journal/payments/preview,POST /api/journal/payee-verifications,POST /api/journal/export,POST /api/journal/ingestPOST /api/convert/*,POST /api/convert-async/*,POST /api/validate/*,POST /api/inbound/*POST /api/webhooksandPOST /api/webhooks/*GET /api/journal/list,GET /api/journal/documents,GET /api/journal/events,GET /api/journal/reconciliation,GET /api/journal/audit/verify- connection setup and admin actions
⚠️ /journal/payments/preview is on the list. It runs the same convert and validate steps as a real payment, so it costs the same budget. Don't preview in a tight loop.
⚠️ So are the five GET journal routes above. GET /journal/list and GET /journal/reconciliation are exactly the routes Receiving results recommends for polling; they spend the same 300/60 s bucket a payment submission does. A tight poll interval, or a poller sharing an API key with live payment traffic, can 429 on the read alone, with no POST involved. GET /journal/audit/verify re-reads the whole activity log on every call, so call it when someone asks, never on a timer. Every other GET (a single document, its file, its payments, /journal/payment-types, and everything outside /journal) is not on this list.
Other routes are not counted against this bucket, but the per-IP cap below applies.
⚠️ For API keys the bucket is per key. All traffic sent with the same key, every worker, shares one 300/min bucket. If you run parallel workers on one key, coordinate so you don't starve yourself.
2. The pre-auth per-IP cap (600 / 60 s): GETs included
Before your credential is even resolved, every API request is counted against a per-source-IP cap: 600 requests per 60 seconds.
This cap ignores the method, so GETs count too:
- A poller behind a NAT / shared egress IP (a typical on-prem ERP, or several workers behind one gateway) shares that 600/min with everything else leaving that IP, and can
429on plainGETs. - Both limits return the same
429 rate_limitedwithRetry-After.
Keep your polling interval sane (seconds, not milliseconds), back off on 429 even for reads, and prefer webhooks over tight polling.
Headers
There are no X-RateLimit-* headers. You find out by getting a 429, which carries Retry-After and details.retryAfterSeconds. Implement a token-bucket or concurrency limit on your side rather than probing for the ceiling.
A retry helper
Call it with a function that sends the same request every time: the same body, and the same Idempotency-Key and messageId.
async function errorCode(res: Response): Promise<string | undefined> {
const body = await res.clone().json().catch(() => null);
return body?.error?.code;
}
// 502 bank_outcome_unknown: read the payment first. 500 "unreadable": needs a new messageId.
async function isNeverRetry(res: Response) {
if ((await errorCode(res)) === "bank_outcome_unknown") return true;
return res.status === 500 && /unreadable/i.test(await res.clone().text());
}
async function withRetry<T>(fn: () => Promise<Response>, parse: (r: Response) => Promise<T>) {
const MAX = 5;
for (let attempt = 1; attempt <= MAX; attempt++) {
const res = await fn();
const requestId = res.headers.get("x-request-id");
if (res.ok) return parse(res);
const retryable =
res.status === 429 ||
res.status === 503 ||
(res.status === 409 && (await errorCode(res)) === "request_in_progress") ||
(res.status >= 500 && !(await isNeverRetry(res)));
if (!retryable || attempt === MAX) {
throw new Error(`${res.status} not retryable (request ${requestId})`);
}
const retryAfter = Number(res.headers.get("retry-after")) || 2 ** attempt;
await new Promise(r => setTimeout(r, retryAfter * 1000));
}
throw new Error("unreachable");
}
Always capture X-Request-ID
Every response carries an X-Request-ID. Log it with every call. When you open a support ticket, quote it so we can find the exact request.