sandbox.bankconnector.com API Reference

Errors & retries

Audience: 🔌 Integration Client

The error envelope

Almost every error is:

{ "error": { "code": "validation_failed", "message": "…", "details": { "issues": [ … ] } } }
  • code is a stable, snake_case string. Branch on code, never on message (messages can change and are never localised).
  • details is present only when there's structured extra info (e.g. validation issues).

✅ Every non-2xx response uses this envelope, including 413 (payload_too_large), the idempotency terminal error (internal_error) and the browser-only CSRF 403 (csrf_failed). You can rely on body.error.code everywhere. The API Reference's ErrorResponse schema 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:

fieldwhenmeaning
limitBytesalwaysthe cap, in bytes
observedBytesalwaysyour body size, or the bytes read before the cap fired
observedBytesExactalwaystrue when observedBytes is your whole body
suggestedRequestsyou sent a Content-Lengthsplit into at least this many requests
observedTransactionsno Content-Lengthtransactions counted in what we read
suggestedTransactionsPerRequestno Content-Lengthsend 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_failed means we could not read your request: it was not JSON, a header was malformed, or a path parameter was missing.
  • 422 validation_failed means 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 in error.details.issues either way: a payment's carry a code, every other body's a path and a message.

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 is 400, not 422. Only a rule failure is 422 there. Pin 2026-08-25 for the boundary described above. The changelog has the per-route table.

⚠️ Before 2026-10-20, any other body that fails its route's schema is 400, and a field the route does not read is dropped without a word. The issues are in error.details.issues on 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

StatuscodeRetry?How
429rate_limited✅ yeswait for Retry-After / retryAfterSeconds, then retry
503overloaded✅ yestransient; honour Retry-After (~2 s)
503service_unavailable✅ yesa dependency is down; back off
409conflict (in-progress)✅ yessame request, retry shortly
409conflict (different-body)❌ noyou reused a messageId or key with a changed body; fix it
409message_id_already_used❌ nothat messageId's payment was rejected (by the bank or an approver) or cancelled; resubmit with a new one
409invalid_state❌ nodo the step the message names first, then send the request again
500internal_error⚠️ mayberetry once with backoff; if it persists, capture X-Request-ID and escalate
502sftp_probe_failed etc.⚠️ maybeupstream hiccup; limited retry
400any❌ nofix the request
400bank_rejected❌ nothe bank refused a setup step; its own reply is in error.details (bankText, returnCode, nextSteps)
422mandatory_identifier_missing❌ nosave the identifiers error.details.identifiers names, then activate again
403capability_disabled❌ nothe feature (e.g. webhooks) is not enabled for your organisation
404raw_file_expired❌ nothe raw statement file passed its retention window; the statement JSON stays readable
401unauthorized❌ no*re-authenticate (check the key); not a blind retry
403forbidden / admin_required❌ noscope/role problem; will never succeed as-is
404*_not_found❌ nowrong id / resource
422validation_failed etc.❌ nofix 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 same messageId or Idempotency-Key arrived with a different payload → do not retry, or
  • message_id_already_used: the payment you first sent under this messageId was 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 new messageId. A new Idempotency-Key does not help, because the messageId is 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/ingest
  • POST /api/convert/, POST /api/convert-async/, POST /api/validate/, POST /api/inbound/
  • POST /api/webhooks and /webhooks/*
  • POST /api/connections, every POST under /api/connections/* (activate, ws-certificate, test-connection, generate-pgp, …), and DELETE /api/connections/{id} (deleting the channel)
  • POST /api/approvals//approve, /reject, POST /api/banks//test-payment
  • POST /api/users (invite), POST /api/users/*/reinvite, POST /api/partner/firm/members (a partner firm's invite), POST /api/admin/test-email
  • POST /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 gets 403 admin_required before it can even reach this bucket
  • GET /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 only
  • GET /api/journal/list, GET /api/journal/documents, GET /api/journal/events, GET /api/journal/reconciliation, GET /api/journal/audit/verify
  • GET /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 429 on plain GETs.
  • Both limits return the same 429 rate_limited with Retry-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.