sandbox.bankconnector.com API Reference

Errors & Retries

Audience: 🔌 Integration Client

Getting error handling right is most of what separates a fragile integration from a reliable one. This chapter gives you a model you can code against directly.

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 are human text and can change; they are never localised).
  • details is present only when there's structured extra info (e.g. validation issues).

⚠️ The envelope is not universal. A few responses are intentionally flat { "error": "<string>" }:

  • 413 body-too-large
  • the idempotency terminal-error body
  • the CSRF 403 (browser only — you won't hit it as an API client)

Everywhere else, error is always an object. If you're distinguishing the two shapes in code, check typeof body.error === "string" for the flat variant. The API Reference's ErrorResponse schema now matches this object envelope (it previously described the legacy flat shape — fixed).

The 400 vs 422 boundary

This distinction is deliberate and useful:

  • 400 validation_failed — the request is malformed: not JSON, not an object, a structural problem. Your code built the request wrong.
  • 422 validation_failed — the request is well-formed but the payment fails a schema or business rule. The specific problems are in error.details.issues. The data is wrong, not the request.

So: 400 → fix your request construction; 422 → surface the issues to whoever supplied the payment data. Neither is retryable as-is.

What's retryable

StatuscodeRetry?How
429rate_limited✅ yeswait for Retry-After / retryAfterSeconds, then retry
503overloaded✅ yestransient (converter pool busy); honour Retry-After (~2 s)
503service_unavailable✅ yesa dependency is down; back off
409conflict (in-progress)✅ yessame idempotency key, retry shortly
409conflict (different-body)❌ noyou reused a key with a changed body — fix it
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
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 two faces of 409

Because it's the one that bites people: on POST /journal/payments, 409 means either

  • in-progress — "…already being processed. Retry shortly." → retry (same key), or
  • different-body — "…already used with a different request payload." → do not retry; it's a client bug.

Tell them apart by the message text, then branch. (Details in Idempotency.)

Rate limiting

  • The limit is 300 requests per 60 seconds per caller, on POST routes only (payments, convert, inbound, webhooks, approvals, etc.). GETs are not limited — poll freely.
  • ⚠️ **For API keys the bucket is per platform. All of your integration's traffic — every company, every worker — shares one** 300/min bucket. If you run parallel workers, coordinate so you don't starve yourself.
  • 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, quoting it lets the operator look up the exact request in the exception log. It's the single most useful thing you can record.