Errors & retries
Audience: 🔌 Integration Client
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 uses this envelope, including
413(payload_too_large), the idempotency terminal error (internal_error) and the browser-only CSRF403(csrf_failed). You can rely onbody.error.codeeverywhere. The API Reference'sErrorResponseschema matches.
413 payload_too_large tells you how to split
The request-body cap is 1 MB on the payment routes and 8 MB on the three routes you deliver a raw bank file to (POST /journal/ingest, POST /inbound/statement, POST /inbound/payment-status), because a statement is one file and there is nothing to split. 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
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 | conflict (in-progress) | ✅ yes | same request, retry shortly |
409 | conflict (different-body) | ❌ 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 |
502 | sftp_probe_failed etc. | ⚠️ maybe | upstream hiccup; limited retry |
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, in-progress 409, and (cautiously) 5xx. Never blind-retry a 4xx other than in-progress 409.
The three faces of 409
On POST /api/journal/payments, 409 means one of three things:
- in-progress (
conflict): "…already being processed. Retry shortly." → retry the same request, or - different-body (
conflict): the samemessageIdorIdempotency-Keyarrived with a different payload → do not retry, or message_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.
Branch on code first; the two conflicts are told apart by the message text ("being processed" is the in-progress one). (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, a signer's live review of a held payment, and one DELETE. This is the complete list:
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/webhooksand/webhooks/*POST /api/connections, everyPOSTunder/api/connections/*(activate, ws-certificate, test-connection, generate-pgp, …), andDELETE /api/connections/{id}(deleting the channel)POST /api/approvals//approve,/reject,POST /api/banks//test-paymentPOST /api/users(invite),POST /api/users/*/reinvite,POST /api/partner/firm/members(a partner firm's invite),POST /api/admin/test-emailPOST /api/company/demo-seed,POST /api/company/demo-reset,POST /api/demo/reset,POST /api/demo/statements(an ERP add-on's Reset demo and its demo statements)POST /api/onboarding/partner/uniconta-pid-proposals,POST /api/partner/firm/uniconta-pid-proposals(an ERP add-on, or a partner firm, proposing the partner's Uniconta number)POST /api/billing/checkout,POST /api/billing/portal,POST /api/billing/change-package: admin-session only; an API key gets403 admin_requiredbefore it can even reach this bucketGET /api/journal/documents/{id}/signer-release,POST /api/journal/documents/{id}/signer-release: a signer's review of, and decision on, a payment the bank holds after its payee check; both ask the bank live. Approver-session onlyGET /api/journal/list,GET /api/journal/documents,GET /api/journal/events,GET /api/journal/reconciliation,GET /api/journal/audit/verifyGET /api/partner/addon-releases/{id}/file(a partner firm downloading an ERP add-on package)
⚠️ /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 request to a non-public route 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
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 && /being processed/i.test(await res.clone().text())) ||
res.status >= 500;
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.