Errors ⚠️

The Cardda API uses standard HTTP status codes. Most error responses ship a JSON body in one of three shapes:

{ "error": "<machine_code>", "message": "<human readable description>" }
{ "errors": { "field_name": ["error 1", "error 2"], ... } }
{ "message": "<human readable description>" }

The first shape is used for transport-level errors (auth, rate limit, …); message is optional. The second shape is used for validation errors on POST / PATCH / PUT requests. The third — message only, with no error field — is used by a handful of action-level guards (the {"message":"Unauthorized (401)"} responses described below) and by the 416 page-size-cap response. Don't write a parser that requires an error field: branch on the HTTP status first, then read message (and error when present).

⚠️

A subset of 401 and 403 responses ship with an empty body. Specifically, the guards in ApplicationController for missing/invalid authentication, missing company-id, and Pundit-denied actions render status: :unauthorized with no body, and a handful of action-level guards render status: :forbidden with no body. A separate set of action-level guards (admin-only mutations, company-scope mismatches across bank_payrolls, bank_recipients, bank_transactions, payables, purchase_orders, etc.) instead render status: :unauthorized with a JSON body of the shape {"message": "Unauthorized (401)"} — this is the shape published in the per-endpoint OpenAPI pages. Branch your client logic on response.status first; parse the body defensively — read it as text and skip JSON parsing if the string is empty, or wrap JSON.parse in a try/catch (don't rely on Content-Length, since proxies or chunked transfer can omit it). We're consolidating these shapes in a follow-up — until then, treat the body as opaque: a 401/403 may arrive empty or as JSON depending on which guard tripped.

Status code reference

HTTPerror codeWhen you see thisNotes
400bad_requestThe request body or query string is malformed (invalid JSON, wrong types, missing required field).Inspect the message field. Fix and retry.
401(empty body)Missing or invalid Authorization header, missing company-id when required, or a require_* guard in ApplicationController denied access.Branch on the status, not the body. Add the header or refresh the key. See The company-id header.
401{"message":"Unauthorized (401)"}An action-level guard rejected the request (admin-only mutation, company-scope mismatch on bank_payrolls/bank_recipients/bank_transactions/payables/purchase_orders/etc.). This is the shape documented in the per-endpoint OpenAPI pages.Branch on the status; parse the body defensively (it may also arrive empty for the ApplicationController guards above).
401email_not_verifiedThe user owning the API key has not verified their email (richer JSON body).Ask the user to confirm their email.
403(empty body)An action-level guard denied this request without a body (e.g. card creation gated by issuer state, Sofia API key without the right scope).Branch on the status, not the body. The empty body is intentional.
403richer JSON (error_code / message)An action-level guard denied this request and shipped a JSON explanation. Examples: EMAIL_NOT_VERIFIED (Firebase user without a verified email), bank_keys/kyc_incomplete (company KYC pending), or attempting to assign the owner role without being one.Inspect error_code (when present) and message. Field names vary by endpoint, so don't rely on a single shape.
404not_foundResource not found, or company-id references a company the user is not a member of.We do not leak existence — 404 is returned in both cases by design.
409conflictThe action conflicts with current state (e.g. trying to authorize a transaction that is already authorized, enrolling a recipient that already exists).Re-fetch the resource and reconcile.
416(page size cap)The pagination window (_end - _start) exceeded the server-side maximum of 2 000 rows. Body shape: {"message": "Requested page size <N> exceeds maximum of 2000"}.Reduce the window. The recommended page size is 100; see Pagination.
422unprocessable_entityValidation failed on POST / PATCH / PUT.Body uses the { errors: { field: [...] } } shape.
429rate_limit_exceededToo many requests.See Rate limits and inspect the Retry-After header.
500internal_errorUnexpected error on Cardda's side.Retry with exponential backoff; if it persists, contact us with the X-Request-Id response header.
502 / 503 / 504upstream/transientTemporary upstream issue (a banking partner, a card issuer).Retry with backoff.

Examples

Missing company-id

$ curl -i https://api.cardda.com/v1/banking/bank_transactions \
    -H "Authorization: Bearer YOUR_API_KEY"

HTTP/1.1 401 Unauthorized
Content-Length: 0

The body is empty by design. Do not try to JSON.parse it — branch on the 401 status alone and prompt the user to set the company-id header.

Validation error on create

$ curl -i -X POST https://api.cardda.com/v1/banking/bank_recipients \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "company-id: 550e8400-e29b-41d4-a716-446655440000" \
    -H "Content-Type: application/json" \
    -d '{"name": "Acme Vendor"}'

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "errors": {
    "rut": ["can't be blank", "is invalid"],
    "account_number": ["can't be blank"],
    "bank_id": ["must reference an existing bank"]
  }
}

Rate limited

HTTP/1.1 429 Too Many Requests
Retry-After: 1
Content-Type: application/json

{
  "error": "rate_limit_exceeded",
  "message": "Too many requests. Please retry after 1 second.",
  "retry_after": 1
}

Page size too large

$ curl -i 'https://api.cardda.com/v1/banking/bank_transactions?_start=0&_end=5000' \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "company-id: 550e8400-e29b-41d4-a716-446655440000"

HTTP/1.1 416 Range Not Satisfiable
Content-Type: application/json

{ "message": "Requested page size 5000 exceeds maximum of 2000" }

The cap is enforced server-side to prevent a sync script from mistaking a silently-truncated page for the end of the result set. Reduce _end - _start to ≤ 2 000 (the recommended page size is 100) and walk the collection page by page — see Pagination.

Resource not found / not your company

HTTP/1.1 404 Not Found
Content-Type: application/json

{"error": "not_found"}

Recommended client-side handling

async function carddaRequest(path, init) {
  const res = await fetch(`https://api.cardda.com${path}`, init);
  if (res.ok) return res.json();

  let body = {};
  try { body = await res.json(); } catch {}

  switch (res.status) {
    case 401:
      // Ask the user to re-authenticate or refresh the company list
      throw new AuthError(body);
    case 403:
      throw new PermissionError(body);
    case 404:
      throw new NotFoundError(body);
    case 422:
      throw new ValidationError(body.errors);
    case 416:
      // Caller asked for more than 2 000 rows in a single page. Body is JSON
      // with { message }. Don't retry without shrinking the window.
      throw new PageSizeTooLargeError(body);
    case 429: {
      const retryAfter = Number(res.headers.get("Retry-After") ?? "1");
      throw new RateLimitError({ retryAfter });
    }
    case 500:
    case 502:
    case 503:
    case 504:
      throw new TransientError(body, { requestId: res.headers.get("X-Request-Id") });
    default:
      throw new HttpError(res.status, body);
  }
}

Tracing an error

Every Cardda response includes an X-Request-Id header. When you contact support about a failing request, paste that ID in the message — we can pull the full trace from our observability tooling instantly.

$ curl -i https://api.cardda.com/v1/banking/bank_transactions ...
HTTP/1.1 500 Internal Server Error
X-Request-Id: 8f3c2a7e-1b4d-4f9a-b2c5-9d7e8f1a2b3c

Tip. Log the X-Request-Id for every non-2xx response in your client. Future-you will thank past-you.