sandbox.bankconnector.com API Reference

Errors & retries

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 from the API uses this envelope, including 413 (payload_too_large) and the idempotency terminal error (internal_error), so you can rely on body.error.code. The API Reference's ErrorResponse schema 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:

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

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_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
409request_in_progress✅ yesthe same request is still running; send it again unchanged, shortly
409conflict❌ 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
500internal_error (unreadable stored response)❌ nothe message says the stored response is unreadable; it stays so for 90 days. Submit again with a new messageId
502bank_unavailable✅ yesthe bank is not reachable; retry later with backoff
502bank_outcome_unknown❌ nonever retry blind: read the payment's status again first
502sftp_probe_failed etc.⚠️ maybeupstream hiccup; limited retry
400unsupported_pain_version❌ nothe bank does not take that pain.001 version; change the version or the bank
402billing_past_due❌ noan invoice has been unpaid for over 30 days; new production payments are refused until it is paid
402trial_payments_exhausted❌ nothe ten free production payments are used and there is no package; subscribe, then send again
422sends_paused❌ nonew payments are paused for your organisation; contact support
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, 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, or
  • 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.

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/ingest
  • POST /api/convert/*, POST /api/convert-async/*, POST /api/validate/*, POST /api/inbound/*
  • POST /api/webhooks and POST /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 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

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.